Prechádzať zdrojové kódy

feat:平台管理里面几个模块接口联调

韩洋 1 mesiac pred
rodič
commit
4ccc483076
50 zmenil súbory, kde vykonal 3124 pridanie a 898 odobranie
  1. 2 0
      .dbg/role-actions-click.env
  2. 72 0
      .dbg/trae-debug-log-role-actions-click.ndjson
  3. 9 0
      classic/src/components/auth/LoginForm.jsx
  4. 32 0
      debug-role-actions-click.md
  5. 93 22
      default/docs/permission-identifiers.json
  6. 797 0
      default/docs/response.json
  7. 1 0
      default/src/components/layout/types.ts
  8. 1 0
      default/src/features/auth/api.ts
  9. 12 4
      default/src/features/auth/hooks/use-auth-redirect.ts
  10. 11 3
      default/src/features/auth/otp/components/otp-form.tsx
  11. 10 0
      default/src/features/auth/sign-in/components/user-auth-form.tsx
  12. 28 4
      default/src/features/auth/sign-up/components/sign-up-form.tsx
  13. 18 4
      default/src/features/auth/sign-up/index.tsx
  14. 2 0
      default/src/features/auth/types.ts
  15. 13 14
      default/src/features/channels/components/channels-primary-buttons.tsx
  16. 44 28
      default/src/features/channels/components/data-table-row-actions.tsx
  17. 38 38
      default/src/features/channels/constants.ts
  18. 1 1
      default/src/features/channels/lib/channel-form.ts
  19. 7 0
      default/src/features/keys/components/api-keys-primary-buttons.tsx
  20. 67 53
      default/src/features/keys/components/data-table-row-actions.tsx
  21. 73 51
      default/src/features/models/components/data-table-row-actions.tsx
  22. 16 5
      default/src/features/models/components/models-columns.tsx
  23. 13 4
      default/src/features/models/components/models-primary-buttons.tsx
  24. 155 0
      default/src/features/permissions/api.ts
  25. 136 8
      default/src/features/roles/api.ts
  26. 482 214
      default/src/features/roles/index.tsx
  27. 45 2
      default/src/features/roles/types.ts
  28. 128 0
      default/src/features/tenants/api.ts
  29. 466 250
      default/src/features/tenants/index.tsx
  30. 71 1
      default/src/features/tenants/types.ts
  31. 27 19
      default/src/features/users/components/data-table-row-actions.tsx
  32. 8 3
      default/src/features/users/components/users-columns.tsx
  33. 61 144
      default/src/features/users/components/users-mutate-drawer.tsx
  34. 10 4
      default/src/features/users/components/users-primary-buttons.tsx
  35. 14 2
      default/src/features/users/components/users-table.tsx
  36. 9 2
      default/src/features/users/lib/user-form.ts
  37. 15 0
      default/src/features/users/types.ts
  38. 14 1
      default/src/hooks/use-sidebar-data.ts
  39. 8 5
      default/src/hooks/use-sidebar-view.ts
  40. 1 1
      default/src/i18n/locales/zh.json
  41. 45 0
      default/src/lib/admin-permissions.ts
  42. 22 0
      default/src/routeTree.gen.ts
  43. 2 2
      default/src/routes/_authenticated/channels/index.tsx
  44. 2 2
      default/src/routes/_authenticated/models/$section.tsx
  45. 2 2
      default/src/routes/_authenticated/models/index.tsx
  46. 11 1
      default/src/routes/_authenticated/route.tsx
  47. 2 2
      default/src/routes/_authenticated/tenants/index.tsx
  48. 2 2
      default/src/routes/_authenticated/users/index.tsx
  49. 25 0
      default/src/routes/token/$tenantUniqueId/register.tsx
  50. 1 0
      default/src/stores/auth-store.ts

+ 2 - 0
.dbg/role-actions-click.env

@@ -0,0 +1,2 @@
+DEBUG_SERVER_URL=http://127.0.0.1:7777/event
+DEBUG_SESSION_ID=role-actions-click

+ 72 - 0
.dbg/trae-debug-log-role-actions-click.ndjson

@@ -0,0 +1,72 @@
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788046612}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 4, "roleName": "只读审计员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788054536}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 4, "roleName": "只读审计员"}, "ts": 1784788054540}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false, "currentRoleId": 4, "currentRoleName": "只读审计员"}, "ts": 1784788054665}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 3, "roleName": "财务管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788055567}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 3, "roleName": "财务管理员"}, "ts": 1784788055567}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false, "currentRoleId": 3, "currentRoleName": "财务管理员"}, "ts": 1784788055639}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 3, "roleName": "财务管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788055733}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 3, "roleName": "财务管理员"}, "ts": 1784788055734}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 3, "roleName": "财务管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788055895}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 3, "roleName": "财务管理员"}, "ts": 1784788055896}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 3, "roleName": "财务管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788056115}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 3, "roleName": "财务管理员"}, "ts": 1784788056115}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": true, "editOpen": true, "deleteOpen": false}, "ts": 1784788097462}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false}, "ts": 1784788099055}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788119199}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788119228}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 2, "roleName": "运营管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788121810}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 2, "roleName": "运营管理员"}, "ts": 1784788121811}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false, "currentRoleId": 2, "currentRoleName": "运营管理员"}, "ts": 1784788121869}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 3, "roleName": "财务管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788122922}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 3, "roleName": "财务管理员"}, "ts": 1784788122923}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false, "currentRoleId": 3, "currentRoleName": "财务管理员"}, "ts": 1784788122994}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 3, "roleName": "财务管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788123001}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 3, "roleName": "财务管理员"}, "ts": 1784788123002}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 3, "roleName": "财务管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788123443}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 3, "roleName": "财务管理员"}, "ts": 1784788123444}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 4, "roleName": "只读审计员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788123531}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 4, "roleName": "只读审计员"}, "ts": 1784788123532}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false, "currentRoleId": 4, "currentRoleName": "只读审计员"}, "ts": 1784788123616}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 4, "roleName": "只读审计员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788123821}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 4, "roleName": "只读审计员"}, "ts": 1784788123821}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "delete-click-start", "payload": {"roleId": 4, "roleName": "只读审计员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788124448}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "delete-click-end", "payload": {"roleId": 4, "roleName": "只读审计员"}, "ts": 1784788124449}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": true, "currentRoleId": 4, "currentRoleName": "只读审计员"}, "ts": 1784788124516}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "delete-click-start", "payload": {"roleId": 4, "roleName": "只读审计员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788124679}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "delete-click-end", "payload": {"roleId": 4, "roleName": "只读审计员"}, "ts": 1784788124680}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "delete-click-start", "payload": {"roleId": 3, "roleName": "财务管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788125199}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "delete-click-end", "payload": {"roleId": 3, "roleName": "财务管理员"}, "ts": 1784788125199}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": true, "currentRoleId": 3, "currentRoleName": "财务管理员"}, "ts": 1784788125261}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788143392}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788143414}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788146935}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788146957}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-start", "payload": {"roleId": 3, "roleName": "财务管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788149636}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "edit-click-end", "payload": {"roleId": 3, "roleName": "财务管理员"}, "ts": 1784788149638}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false, "currentRoleId": 3, "currentRoleName": "财务管理员"}, "ts": 1784788149698}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "delete-click-start", "payload": {"roleId": 3, "roleName": "财务管理员", "eventType": "click", "defaultPrevented": false}, "ts": 1784788150464}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "delete-click-end", "payload": {"roleId": 3, "roleName": "财务管理员"}, "ts": 1784788150465}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": true, "currentRoleId": 3, "currentRoleName": "财务管理员"}, "ts": 1784788150512}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false, "currentRoleId": 3, "currentRoleName": "财务管理员"}, "ts": 1784788530234}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false, "currentRoleId": 3, "currentRoleName": "财务管理员"}, "ts": 1784788530851}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false, "currentRoleId": 1, "currentRoleName": "超级管理员"}, "ts": 1784788533847}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false, "currentRoleId": 1, "currentRoleName": "超级管理员"}, "ts": 1784788590069}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": true, "currentRoleId": 2, "currentRoleName": "运营管理员"}, "ts": 1784788591786}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false, "currentRoleId": 2, "currentRoleName": "运营管理员"}, "ts": 1784788594072}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": true, "currentRoleId": 4, "currentRoleName": "只读审计员"}, "ts": 1784788596017}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false, "currentRoleId": 4, "currentRoleName": "只读审计员"}, "ts": 1784788598123}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788604820}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788604835}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788652225}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784788652262}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false, "currentRoleId": 1, "currentRoleName": "超级管理员"}, "ts": 1784788658594}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false, "currentRoleId": 1, "currentRoleName": "超级管理员"}, "ts": 1784788661057}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": true, "currentRoleId": 1, "currentRoleName": "超级管理员"}, "ts": 1784788663381}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false, "currentRoleId": 1, "currentRoleName": "超级管理员"}, "ts": 1784788665837}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": true, "deleteOpen": false, "currentRoleId": 1, "currentRoleName": "超级管理员"}, "ts": 1784789050430}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false, "currentRoleId": 1, "currentRoleName": "超级管理员"}, "ts": 1784789069608}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": true, "editOpen": false, "deleteOpen": false}, "ts": 1784789071405}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784789074271}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784789212507}
+{"sessionId": "role-actions-click", "runId": "pre", "hypothesisId": "role-actions-click", "event": "open-state-changed", "payload": {"createOpen": false, "editOpen": false, "deleteOpen": false}, "ts": 1784789212542}

+ 9 - 0
classic/src/components/auth/LoginForm.jsx

@@ -67,6 +67,14 @@ import TwoFAVerification from './TwoFAVerification';
 import { useTranslation } from 'react-i18next';
 import { SiDiscord } from 'react-icons/si';
 
+function getTenantUniqueIdFromPath() {
+  const [, tokenPrefix, tenantUniqueId] = window.location.pathname.split('/');
+  if (tokenPrefix !== 'token' || !tenantUniqueId || tenantUniqueId === 'token_main') {
+    return '';
+  }
+  return tenantUniqueId;
+}
+
 const LoginForm = () => {
   let navigate = useNavigate();
   const { t } = useTranslation();
@@ -233,6 +241,7 @@ const LoginForm = () => {
           {
             username,
             password,
+            tenant_unique_id: getTenantUniqueIdFromPath(),
           },
         );
         const { success, message, data } = res.data;

+ 32 - 0
debug-role-actions-click.md

@@ -0,0 +1,32 @@
+# Debug Session: role-actions-click
+
+Status: [OPEN]
+
+## Symptom
+
+平台角色列表中点击编辑和删除按钮没有反应。
+
+## Hypotheses
+
+1. 操作列按钮点击事件没有触发,可能被表格单元格或截断容器拦截。
+2. 点击触发了,但弹层 open 状态没有正确更新。
+3. 当前页面渲染的列定义不是最新的操作列定义。
+4. 弹层打开时发生运行时错误,导致界面看起来没有反应。
+5. 表格固定高度或层级样式导致按钮不可交互。
+
+## Evidence Plan
+
+- 在编辑/删除按钮点击处理、open 状态变化、弹层渲染位置增加临时调试上报。
+- 复现点击行为后依据日志确认根因。
+
+## Pre-fix Evidence
+
+- `edit-click-start` / `edit-click-end` 均已记录,说明编辑按钮点击事件已触发。
+- `delete-click-start` / `delete-click-end` 均已记录,说明删除按钮点击事件已触发。
+- `open-state-changed` 中 `editOpen: true`、`deleteOpen: true` 均出现,说明状态已经打开。
+- 因此排除“按钮事件未触发”和“open 状态未更新”,根因指向弹层挂载/触发方式导致 UI 不可见。
+
+## Fix Plan
+
+- 将编辑抽屉、删除确认弹窗改为与新建角色一致的 Trigger 挂载方式。
+- 保留临时日志,等待 post-fix 验证。

+ 93 - 22
default/docs/permission-identifiers.json

@@ -1,15 +1,28 @@
 [
   {
     "module_name": "总览",
-    "module_code": "dashboard.view",
+    "module_code": "route.dashboard.view",
     "module_label": "Dashboard",
-    "children": []
+    "children": [
+      {
+        "perm_name": "查看",
+        "front_perm_code": "dashboard.view",
+        "api_perm_code": "",
+        "perm_label": "View Dashboard"
+      }
+    ]
   },
   {
     "module_name": "渠道管理",
-    "module_code": "channel.view",
+    "module_code": "route.channel.view",
     "module_label": "Channel Management",
     "children": [
+      {
+        "perm_name": "查看",
+        "front_perm_code": "channel.view",
+        "api_perm_code": "",
+        "perm_label": "View Channel"
+      },
       {
         "perm_name": "新增",
         "front_perm_code": "channel.create",
@@ -38,9 +51,15 @@
   },
   {
     "module_name": "模型管理",
-    "module_code": "models.view",
+    "module_code": "route.models.view",
     "module_label": "Model Management",
     "children": [
+      {
+        "perm_name": "查看",
+        "front_perm_code": "models.view",
+        "api_perm_code": "",
+        "perm_label": "View Model"
+      },
       {
         "perm_name": "新增",
         "front_perm_code": "models.create",
@@ -69,9 +88,15 @@
   },
   {
     "module_name": "用户管理",
-    "module_code": "user.view",
+    "module_code": "route.user.view",
     "module_label": "User Management",
     "children": [
+      {
+        "perm_name": "查看",
+        "front_perm_code": "user.view",
+        "api_perm_code": "",
+        "perm_label": "View User"
+      },
       {
         "perm_name": "新增",
         "front_perm_code": "user.create",
@@ -100,23 +125,29 @@
   },
   {
     "module_name": "平台角色",
-    "module_code": "role.view",
+    "module_code": "route.role.view",
     "module_label": "Role Management",
     "children": [
       {
-        "perm_name": "新增角色",
+        "perm_name": "查看",
+        "front_perm_code": "role.view",
+        "api_perm_code": "",
+        "perm_label": "View Role"
+      },
+      {
+        "perm_name": "新增",
         "front_perm_code": "role.create",
         "api_perm_code": "",
         "perm_label": "Create Role"
       },
       {
-        "perm_name": "编辑角色",
+        "perm_name": "编辑",
         "front_perm_code": "role.update",
         "api_perm_code": "",
         "perm_label": "Update Role"
       },
       {
-        "perm_name": "删除角色",
+        "perm_name": "删除",
         "front_perm_code": "role.delete",
         "api_perm_code": "",
         "perm_label": "Delete Role"
@@ -125,23 +156,29 @@
   },
   {
     "module_name": "租户管理",
-    "module_code": "tenant.view",
+    "module_code": "route.tenant.view",
     "module_label": "Tenant Management",
     "children": [
       {
-        "perm_name": "新增租户",
+        "perm_name": "查看",
+        "front_perm_code": "tenant.view",
+        "api_perm_code": "",
+        "perm_label": "View Tenant"
+      },
+      {
+        "perm_name": "新增",
         "front_perm_code": "tenant.create",
         "api_perm_code": "",
         "perm_label": "Create Tenant"
       },
       {
-        "perm_name": "编辑租户",
+        "perm_name": "编辑",
         "front_perm_code": "tenant.update",
         "api_perm_code": "",
         "perm_label": "Update Tenant"
       },
       {
-        "perm_name": "禁用租户",
+        "perm_name": "禁用",
         "front_perm_code": "tenant.disable",
         "api_perm_code": "",
         "perm_label": "Disable Tenant"
@@ -150,15 +187,28 @@
   },
   {
     "module_name": "个人信息",
-    "module_code": "profile.view",
+    "module_code": "route.profile.view",
     "module_label": "Profile",
-    "children": []
+    "children": [
+      {
+        "perm_name": "查看",
+        "front_perm_code": "profile.view",
+        "api_perm_code": "",
+        "perm_label": "View Profile"
+      }
+    ]
   },
   {
     "module_name": "API 密钥",
-    "module_code": "keys.view",
+    "module_code": "route.keys.view",
     "module_label": "API Keys",
     "children": [
+      {
+        "perm_name": "查看",
+        "front_perm_code": "keys.view",
+        "api_perm_code": "",
+        "perm_label": "View API Keys"
+      },
       {
         "perm_name": "新增",
         "front_perm_code": "keys.create",
@@ -187,20 +237,41 @@
   },
   {
     "module_name": "模型列表",
-    "module_code": "pricing.view",
+    "module_code": "route.pricing.view",
     "module_label": "Model List",
-    "children": []
+    "children": [
+      {
+        "perm_name": "查看",
+        "front_perm_code": "pricing.view",
+        "api_perm_code": "",
+        "perm_label": "View pricing List"
+      }
+    ]
   },
   {
     "module_name": "调用日志",
-    "module_code": "usage-logs.view",
+    "module_code": "route.usage-logs.view",
     "module_label": "Usage Logs",
-    "children": []
+    "children": [
+      {
+        "perm_name": "查看",
+        "front_perm_code": "usage-logs.view",
+        "api_perm_code": "",
+        "perm_label": "View Usage Logs"
+      }
+    ]
   },
   {
     "module_name": "系统设置",
-    "module_code": "settings.view",
+    "module_code": "route.settings.view",
     "module_label": "System Settings",
-    "children": []
+    "children": [
+      {
+        "perm_name": "查看",
+        "front_perm_code": "settings.view",
+        "api_perm_code": "",
+        "perm_label": "View System Settings"
+      }
+    ]
   }
 ]

+ 797 - 0
default/docs/response.json

@@ -0,0 +1,797 @@
+{
+    "data": [
+        {
+            "id": 25,
+            "module_name": "总览",
+            "module_code": "route.dashboard.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.092297+08:00",
+            "updated_at": "2026-07-27T17:56:39.092297+08:00",
+            "DeletedAt": null,
+            "has_permission": true,
+            "module_relation_permissions": [
+                {
+                    "id": 54,
+                    "module_id": 25,
+                    "permission_id": 54,
+                    "created_at": "2026-07-27T17:56:39.932523+08:00",
+                    "updated_at": "2026-07-27T17:56:39.932523+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 54,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "dashboard.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.317823+08:00",
+                        "updated_at": "2026-07-27T17:56:39.317823+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": true
+                }
+            ]
+        },
+        {
+            "id": 26,
+            "module_name": "渠道管理",
+            "module_code": "route.channel.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.110475+08:00",
+            "updated_at": "2026-07-27T17:56:39.110475+08:00",
+            "DeletedAt": null,
+            "has_permission": true,
+            "module_relation_permissions": [
+                {
+                    "id": 55,
+                    "module_id": 26,
+                    "permission_id": 55,
+                    "created_at": "2026-07-27T17:56:39.95093+08:00",
+                    "updated_at": "2026-07-27T17:56:39.95093+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 55,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "channel.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.336055+08:00",
+                        "updated_at": "2026-07-27T17:56:39.336055+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": true
+                },
+                {
+                    "id": 56,
+                    "module_id": 26,
+                    "permission_id": 56,
+                    "created_at": "2026-07-27T17:56:39.969015+08:00",
+                    "updated_at": "2026-07-27T17:56:39.969015+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 56,
+                        "perm_name": "新增",
+                        "perm_desc": "",
+                        "front_perm_code": "channel.create",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.3542+08:00",
+                        "updated_at": "2026-07-27T17:56:39.3542+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 57,
+                    "module_id": 26,
+                    "permission_id": 57,
+                    "created_at": "2026-07-27T17:56:39.987134+08:00",
+                    "updated_at": "2026-07-27T17:56:39.987134+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 57,
+                        "perm_name": "编辑",
+                        "perm_desc": "",
+                        "front_perm_code": "channel.update",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.372355+08:00",
+                        "updated_at": "2026-07-27T17:56:39.372355+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 58,
+                    "module_id": 26,
+                    "permission_id": 58,
+                    "created_at": "2026-07-27T17:56:40.005375+08:00",
+                    "updated_at": "2026-07-27T17:56:40.005375+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 58,
+                        "perm_name": "删除",
+                        "perm_desc": "",
+                        "front_perm_code": "channel.delete",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.390474+08:00",
+                        "updated_at": "2026-07-27T17:56:39.390474+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 59,
+                    "module_id": 26,
+                    "permission_id": 59,
+                    "created_at": "2026-07-27T17:56:40.023554+08:00",
+                    "updated_at": "2026-07-27T17:56:40.023554+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 59,
+                        "perm_name": "禁用",
+                        "perm_desc": "",
+                        "front_perm_code": "channel.disable",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.408532+08:00",
+                        "updated_at": "2026-07-27T17:56:39.408532+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                }
+            ]
+        },
+        {
+            "id": 28,
+            "module_name": "用户管理",
+            "module_code": "route.user.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.146466+08:00",
+            "updated_at": "2026-07-27T17:56:39.146466+08:00",
+            "DeletedAt": null,
+            "has_permission": true,
+            "module_relation_permissions": [
+                {
+                    "id": 65,
+                    "module_id": 28,
+                    "permission_id": 65,
+                    "created_at": "2026-07-27T17:56:40.132666+08:00",
+                    "updated_at": "2026-07-27T17:56:40.132666+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 65,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "user.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.517358+08:00",
+                        "updated_at": "2026-07-27T17:56:39.517358+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": true
+                },
+                {
+                    "id": 66,
+                    "module_id": 28,
+                    "permission_id": 66,
+                    "created_at": "2026-07-27T17:56:40.15085+08:00",
+                    "updated_at": "2026-07-27T17:56:40.15085+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 66,
+                        "perm_name": "新增",
+                        "perm_desc": "",
+                        "front_perm_code": "user.create",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.53548+08:00",
+                        "updated_at": "2026-07-27T17:56:39.53548+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 67,
+                    "module_id": 28,
+                    "permission_id": 67,
+                    "created_at": "2026-07-27T17:56:40.169035+08:00",
+                    "updated_at": "2026-07-27T17:56:40.169035+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 67,
+                        "perm_name": "编辑",
+                        "perm_desc": "",
+                        "front_perm_code": "user.update",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.55358+08:00",
+                        "updated_at": "2026-07-27T17:56:39.55358+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 68,
+                    "module_id": 28,
+                    "permission_id": 68,
+                    "created_at": "2026-07-27T17:56:40.187197+08:00",
+                    "updated_at": "2026-07-27T17:56:40.187197+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 68,
+                        "perm_name": "删除",
+                        "perm_desc": "",
+                        "front_perm_code": "user.delete",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.571738+08:00",
+                        "updated_at": "2026-07-27T17:56:39.571738+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 69,
+                    "module_id": 28,
+                    "permission_id": 69,
+                    "created_at": "2026-07-27T17:56:40.205445+08:00",
+                    "updated_at": "2026-07-27T17:56:40.205445+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 69,
+                        "perm_name": "禁用",
+                        "perm_desc": "",
+                        "front_perm_code": "user.disable",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.589793+08:00",
+                        "updated_at": "2026-07-27T17:56:39.589793+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                }
+            ]
+        },
+        {
+            "id": 29,
+            "module_name": "平台角色",
+            "module_code": "route.role.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.164434+08:00",
+            "updated_at": "2026-07-27T17:56:39.164434+08:00",
+            "DeletedAt": null,
+            "has_permission": true,
+            "module_relation_permissions": [
+                {
+                    "id": 70,
+                    "module_id": 29,
+                    "permission_id": 70,
+                    "created_at": "2026-07-27T17:56:40.223627+08:00",
+                    "updated_at": "2026-07-27T17:56:40.223627+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 70,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "role.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.607921+08:00",
+                        "updated_at": "2026-07-27T17:56:39.607921+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": true
+                },
+                {
+                    "id": 71,
+                    "module_id": 29,
+                    "permission_id": 71,
+                    "created_at": "2026-07-27T17:56:40.241862+08:00",
+                    "updated_at": "2026-07-27T17:56:40.241862+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 71,
+                        "perm_name": "新增",
+                        "perm_desc": "",
+                        "front_perm_code": "role.create",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.626033+08:00",
+                        "updated_at": "2026-07-27T17:56:39.626033+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 72,
+                    "module_id": 29,
+                    "permission_id": 72,
+                    "created_at": "2026-07-27T17:56:40.260093+08:00",
+                    "updated_at": "2026-07-27T17:56:40.260093+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 72,
+                        "perm_name": "编辑",
+                        "perm_desc": "",
+                        "front_perm_code": "role.update",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.643991+08:00",
+                        "updated_at": "2026-07-27T17:56:39.643991+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 73,
+                    "module_id": 29,
+                    "permission_id": 73,
+                    "created_at": "2026-07-27T17:56:40.278201+08:00",
+                    "updated_at": "2026-07-27T17:56:40.278201+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 73,
+                        "perm_name": "删除",
+                        "perm_desc": "",
+                        "front_perm_code": "role.delete",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.662037+08:00",
+                        "updated_at": "2026-07-27T17:56:39.662037+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                }
+            ]
+        },
+        {
+            "id": 30,
+            "module_name": "租户管理",
+            "module_code": "route.tenant.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.182603+08:00",
+            "updated_at": "2026-07-27T17:56:39.182603+08:00",
+            "DeletedAt": null,
+            "has_permission": true,
+            "module_relation_permissions": [
+                {
+                    "id": 74,
+                    "module_id": 30,
+                    "permission_id": 74,
+                    "created_at": "2026-07-27T17:56:40.296309+08:00",
+                    "updated_at": "2026-07-27T17:56:40.296309+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 74,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "tenant.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.680075+08:00",
+                        "updated_at": "2026-07-27T17:56:39.680075+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": true
+                },
+                {
+                    "id": 75,
+                    "module_id": 30,
+                    "permission_id": 75,
+                    "created_at": "2026-07-27T17:56:40.314398+08:00",
+                    "updated_at": "2026-07-27T17:56:40.314398+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 75,
+                        "perm_name": "新增",
+                        "perm_desc": "",
+                        "front_perm_code": "tenant.create",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.698081+08:00",
+                        "updated_at": "2026-07-27T17:56:39.698081+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 76,
+                    "module_id": 30,
+                    "permission_id": 76,
+                    "created_at": "2026-07-27T17:56:40.332434+08:00",
+                    "updated_at": "2026-07-27T17:56:40.332434+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 76,
+                        "perm_name": "编辑",
+                        "perm_desc": "",
+                        "front_perm_code": "tenant.update",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.716056+08:00",
+                        "updated_at": "2026-07-27T17:56:39.716056+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 77,
+                    "module_id": 30,
+                    "permission_id": 77,
+                    "created_at": "2026-07-27T17:56:40.350493+08:00",
+                    "updated_at": "2026-07-27T17:56:40.350493+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 77,
+                        "perm_name": "禁用",
+                        "perm_desc": "",
+                        "front_perm_code": "tenant.disable",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.734054+08:00",
+                        "updated_at": "2026-07-27T17:56:39.734054+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                }
+            ]
+        },
+        {
+            "id": 31,
+            "module_name": "个人信息",
+            "module_code": "route.profile.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.200784+08:00",
+            "updated_at": "2026-07-27T17:56:39.200784+08:00",
+            "DeletedAt": null,
+            "has_permission": true,
+            "module_relation_permissions": [
+                {
+                    "id": 78,
+                    "module_id": 31,
+                    "permission_id": 78,
+                    "created_at": "2026-07-27T17:56:40.368629+08:00",
+                    "updated_at": "2026-07-27T17:56:40.368629+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 78,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "profile.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.752008+08:00",
+                        "updated_at": "2026-07-27T17:56:39.752008+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": true
+                }
+            ]
+        },
+        {
+            "id": 32,
+            "module_name": "API 密钥",
+            "module_code": "route.keys.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.218892+08:00",
+            "updated_at": "2026-07-27T17:56:39.218892+08:00",
+            "DeletedAt": null,
+            "has_permission": true,
+            "module_relation_permissions": [
+                {
+                    "id": 79,
+                    "module_id": 32,
+                    "permission_id": 79,
+                    "created_at": "2026-07-27T17:56:40.386714+08:00",
+                    "updated_at": "2026-07-27T17:56:40.386714+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 79,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "keys.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.77002+08:00",
+                        "updated_at": "2026-07-27T17:56:39.77002+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": true
+                },
+                {
+                    "id": 80,
+                    "module_id": 32,
+                    "permission_id": 80,
+                    "created_at": "2026-07-27T17:56:40.404829+08:00",
+                    "updated_at": "2026-07-27T17:56:40.404829+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 80,
+                        "perm_name": "新增",
+                        "perm_desc": "",
+                        "front_perm_code": "keys.create",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.788014+08:00",
+                        "updated_at": "2026-07-27T17:56:39.788014+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 81,
+                    "module_id": 32,
+                    "permission_id": 81,
+                    "created_at": "2026-07-27T17:56:40.422908+08:00",
+                    "updated_at": "2026-07-27T17:56:40.422908+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 81,
+                        "perm_name": "编辑",
+                        "perm_desc": "",
+                        "front_perm_code": "keys.update",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.806039+08:00",
+                        "updated_at": "2026-07-27T17:56:39.806039+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 82,
+                    "module_id": 32,
+                    "permission_id": 82,
+                    "created_at": "2026-07-27T17:56:40.440962+08:00",
+                    "updated_at": "2026-07-27T17:56:40.440962+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 82,
+                        "perm_name": "删除",
+                        "perm_desc": "",
+                        "front_perm_code": "keys.delete",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.824044+08:00",
+                        "updated_at": "2026-07-27T17:56:39.824044+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 83,
+                    "module_id": 32,
+                    "permission_id": 83,
+                    "created_at": "2026-07-27T17:56:40.459109+08:00",
+                    "updated_at": "2026-07-27T17:56:40.459109+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 83,
+                        "perm_name": "禁用",
+                        "perm_desc": "",
+                        "front_perm_code": "keys.disable",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.842193+08:00",
+                        "updated_at": "2026-07-27T17:56:39.842193+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                }
+            ]
+        },
+        {
+            "id": 34,
+            "module_name": "调用日志",
+            "module_code": "route.usage-logs.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.255183+08:00",
+            "updated_at": "2026-07-27T17:56:39.255183+08:00",
+            "DeletedAt": null,
+            "has_permission": false,
+            "module_relation_permissions": [
+                {
+                    "id": 85,
+                    "module_id": 34,
+                    "permission_id": 85,
+                    "created_at": "2026-07-27T17:56:40.495284+08:00",
+                    "updated_at": "2026-07-27T17:56:40.495284+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 85,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "usage-logs.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.878489+08:00",
+                        "updated_at": "2026-07-27T17:56:39.878489+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                }
+            ]
+        },
+        {
+            "id": 35,
+            "module_name": "系统设置",
+            "module_code": "route.settings.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.273301+08:00",
+            "updated_at": "2026-07-27T17:56:39.273301+08:00",
+            "DeletedAt": null,
+            "has_permission": false,
+            "module_relation_permissions": [
+                {
+                    "id": 86,
+                    "module_id": 35,
+                    "permission_id": 86,
+                    "created_at": "2026-07-27T17:56:40.513463+08:00",
+                    "updated_at": "2026-07-27T17:56:40.513463+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 86,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "settings.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.89666+08:00",
+                        "updated_at": "2026-07-27T17:56:39.89666+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                }
+            ]
+        },
+        {
+            "id": 27,
+            "module_name": "模型管理",
+            "module_code": "route.models.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.128525+08:00",
+            "updated_at": "2026-07-27T17:56:39.128525+08:00",
+            "DeletedAt": null,
+            "has_permission": true,
+            "module_relation_permissions": [
+                {
+                    "id": 60,
+                    "module_id": 27,
+                    "permission_id": 60,
+                    "created_at": "2026-07-27T17:56:40.041786+08:00",
+                    "updated_at": "2026-07-27T17:56:40.041786+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 60,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "models.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.426654+08:00",
+                        "updated_at": "2026-07-27T17:56:39.426654+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": true
+                },
+                {
+                    "id": 61,
+                    "module_id": 27,
+                    "permission_id": 61,
+                    "created_at": "2026-07-27T17:56:40.060008+08:00",
+                    "updated_at": "2026-07-27T17:56:40.060008+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 61,
+                        "perm_name": "新增",
+                        "perm_desc": "",
+                        "front_perm_code": "models.create",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.444839+08:00",
+                        "updated_at": "2026-07-27T17:56:39.444839+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 62,
+                    "module_id": 27,
+                    "permission_id": 62,
+                    "created_at": "2026-07-27T17:56:40.07816+08:00",
+                    "updated_at": "2026-07-27T17:56:40.07816+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 62,
+                        "perm_name": "编辑",
+                        "perm_desc": "",
+                        "front_perm_code": "models.update",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.463103+08:00",
+                        "updated_at": "2026-07-27T17:56:39.463103+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 63,
+                    "module_id": 27,
+                    "permission_id": 63,
+                    "created_at": "2026-07-27T17:56:40.09639+08:00",
+                    "updated_at": "2026-07-27T17:56:40.09639+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 63,
+                        "perm_name": "删除",
+                        "perm_desc": "",
+                        "front_perm_code": "models.delete",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.481197+08:00",
+                        "updated_at": "2026-07-27T17:56:39.481197+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                },
+                {
+                    "id": 64,
+                    "module_id": 27,
+                    "permission_id": 64,
+                    "created_at": "2026-07-27T17:56:40.114579+08:00",
+                    "updated_at": "2026-07-27T17:56:40.114579+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 64,
+                        "perm_name": "禁用",
+                        "perm_desc": "",
+                        "front_perm_code": "models.disable",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.499274+08:00",
+                        "updated_at": "2026-07-27T17:56:39.499274+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": false
+                }
+            ]
+        },
+        {
+            "id": 33,
+            "module_name": "模型列表",
+            "module_code": "route.pricing.view",
+            "module_desc": "",
+            "created_at": "2026-07-27T17:56:39.23705+08:00",
+            "updated_at": "2026-07-27T17:56:39.23705+08:00",
+            "DeletedAt": null,
+            "has_permission": true,
+            "module_relation_permissions": [
+                {
+                    "id": 84,
+                    "module_id": 33,
+                    "permission_id": 84,
+                    "created_at": "2026-07-27T17:56:40.477198+08:00",
+                    "updated_at": "2026-07-27T17:56:40.477198+08:00",
+                    "DeletedAt": null,
+                    "ExtPermission": {
+                        "id": 84,
+                        "perm_name": "查看",
+                        "perm_desc": "",
+                        "front_perm_code": "pricing.view",
+                        "api_perm_code": "",
+                        "created_at": "2026-07-27T17:56:39.860389+08:00",
+                        "updated_at": "2026-07-27T17:56:39.860389+08:00",
+                        "DeletedAt": null,
+                        "module_relation_permissions": null
+                    },
+                    "has_permission": true
+                }
+            ]
+        }
+    ],
+    "success": true
+}

+ 1 - 0
default/src/components/layout/types.ts

@@ -34,6 +34,7 @@ type BaseNavItem = {
    * `useSidebarView`). Route-level guards still enforce access independently.
    */
   requiredRole?: number
+  permissionCode?: string
 }
 
 /**

+ 1 - 0
default/src/features/auth/api.ts

@@ -43,6 +43,7 @@ export async function login(payload: LoginPayload) {
     {
       username: payload.username,
       password: payload.password,
+      tenant_unique_id: payload.tenant_unique_id ?? '',
     }
   )
   return res.data

+ 12 - 4
default/src/features/auth/hooks/use-auth-redirect.ts

@@ -19,13 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
 import { useNavigate } from '@tanstack/react-router'
 import i18n from 'i18next'
 
+import { getCurrentPermissionsWithRole } from '@/features/permissions/api'
 import type { User } from '@/features/users/types'
 import { getSelf } from '@/lib/api'
-import { useAuthStore } from '@/stores/auth-store'
+import { type AuthUser, useAuthStore } from '@/stores/auth-store'
 
 import { saveUserId } from '../lib/storage'
 
-function getSavedLanguage(user: User): string | undefined {
+function getSavedLanguage(user: User | AuthUser): string | undefined {
   const userData = user as Record<string, unknown>
   if (typeof userData.language === 'string') {
     return userData.language
@@ -68,8 +69,15 @@ export function useAuthRedirect() {
     try {
       const self = await getSelf()
       if (self?.success && self.data) {
-        const user = self.data as User
-        auth.setUser(user)
+        const user = self.data as AuthUser
+        const permissionSnapshot = await getCurrentPermissionsWithRole()
+        auth.setUser({
+          ...user,
+          permissions: {
+            ...user.permissions,
+            ...permissionSnapshot,
+          },
+        })
 
         // Update user ID if not already set
         if (user.id) {

+ 11 - 3
default/src/features/auth/otp/components/otp-form.tsx

@@ -49,15 +49,15 @@ import {
 } from '@/features/auth/constants'
 import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
 import { saveUserId } from '@/features/auth/lib/storage'
+import { getCurrentPermissionsWithRole } from '@/features/permissions/api'
 import {
   isValidOTP,
   isValidBackupCode,
   formatBackupCode,
   cleanBackupCode,
 } from '@/features/auth/lib/validation'
-import type { User } from '@/features/users/types'
 import { cn } from '@/lib/utils'
-import { useAuthStore } from '@/stores/auth-store'
+import { type AuthUser, useAuthStore } from '@/stores/auth-store'
 
 type OtpFormProps = React.HTMLAttributes<HTMLFormElement>
 
@@ -108,7 +108,15 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
       }
 
       // Update auth store
-      auth.setUser(userData as User)
+      const permissionSnapshot = await getCurrentPermissionsWithRole()
+      const user = userData as AuthUser
+      auth.setUser({
+        ...user,
+        permissions: {
+          ...user.permissions,
+          ...permissionSnapshot,
+        },
+      })
 
       // Store user ID in localStorage for compatibility
       if (userData.id) {

+ 10 - 0
default/src/features/auth/sign-in/components/user-auth-form.tsx

@@ -55,6 +55,15 @@ import {
 } from '@/lib/passkey'
 import { cn } from '@/lib/utils'
 
+function getTenantUniqueIdFromPath() {
+  if (typeof window === 'undefined') return ''
+  const [, tokenPrefix, tenantUniqueId] = window.location.pathname.split('/')
+  if (tokenPrefix !== 'token' || !tenantUniqueId || tenantUniqueId === 'token_main') {
+    return ''
+  }
+  return tenantUniqueId
+}
+
 export function UserAuthForm({
   className,
   redirectTo,
@@ -157,6 +166,7 @@ export function UserAuthForm({
         username: data.username,
         password: data.password,
         turnstile: turnstileToken,
+        tenant_unique_id: getTenantUniqueIdFromPath(),
       })
 
       if (res.success) {

+ 28 - 4
default/src/features/auth/sign-up/components/sign-up-form.tsx

@@ -38,7 +38,7 @@ import {
 } from '@/components/ui/form'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
-import { register, wechatLoginByCode } from '@/features/auth/api'
+import { login, register, wechatLoginByCode } from '@/features/auth/api'
 import { LegalConsent } from '@/features/auth/components/legal-consent'
 import { OAuthProviders } from '@/features/auth/components/oauth-providers'
 import { registerFormSchema } from '@/features/auth/constants'
@@ -59,6 +59,13 @@ type SignUpFormProps = React.HTMLAttributes<HTMLFormElement> & {
   submitText?: string
 }
 
+function getTenantUniqueIdFromPath() {
+  if (typeof window === 'undefined') return undefined
+  const [, tokenPrefix, tenantUniqueId, registerPath] = window.location.pathname.split('/')
+  if (tokenPrefix !== 'token' || registerPath !== 'register') return undefined
+  return tenantUniqueId || undefined
+}
+
 export function SignUpForm(props: SignUpFormProps) {
   const { t } = useTranslation()
   const {
@@ -85,7 +92,7 @@ export function SignUpForm(props: SignUpFormProps) {
     setTurnstileToken,
     validateTurnstile,
   } = useTurnstile()
-  const { redirectToLogin, handleLoginSuccess } = useAuthRedirect()
+  const { handleLoginSuccess, redirectTo2FA } = useAuthRedirect()
   const {
     isSending: isSendingCode,
     secondsLeft,
@@ -188,11 +195,28 @@ export function SignUpForm(props: SignUpFormProps) {
         verification_code: verificationCode || undefined,
         aff_code: getAffiliateCode(),
         turnstile: turnstileToken,
+        tenant_unique_id: invitationMode ? getTenantUniqueIdFromPath() : undefined,
       })
 
       if (res?.success) {
-        toast.success(t('Account created! Please sign in'))
-        redirectToLogin()
+        const loginRes = await login({
+          username: data.username,
+          password: data.password,
+          turnstile: turnstileToken,
+        })
+
+        if (loginRes.success) {
+          if (loginRes.data?.require_2fa) {
+            redirectTo2FA()
+            return
+          }
+
+          await handleLoginSuccess(loginRes.data as { id?: number } | null)
+          toast.success(t('Welcome back!'))
+          return
+        }
+
+        toast.error(loginRes.message || t('Login failed'))
       } else {
         toast.error(res?.message || t('Failed to create account'))
       }

+ 18 - 4
default/src/features/auth/sign-up/index.tsx

@@ -26,12 +26,24 @@ import { AuthLayout } from '../auth-layout'
 import { TermsFooter } from '../components/terms-footer'
 import { SignUpForm } from './components/sign-up-form'
 
+function isInvitationRegisterPath() {
+  if (typeof window === 'undefined') return false
+  const [, tokenPrefix, tenantUniqueId, registerPath] = window.location.pathname.split('/')
+  return tokenPrefix === 'token' && Boolean(tenantUniqueId) && registerPath === 'register'
+}
+
+function getInvitationTenantName() {
+  if (typeof window === 'undefined') return ''
+  return new URLSearchParams(window.location.search).get('tenants') || ''
+}
+
 export function SignUp() {
   const { t } = useTranslation()
   const { status } = useStatus()
+  const invitationTenantName = getInvitationTenantName()
   const isInstitutionInvite =
     typeof window !== 'undefined' &&
-    new URLSearchParams(window.location.search).has('ent')
+    (new URLSearchParams(window.location.search).has('ent') || isInvitationRegisterPath())
 
   if (isInstitutionInvite) {
     return (
@@ -44,9 +56,11 @@ export function SignUp() {
                   <ShieldCheck className='size-6' />
                 </div>
                 <h2 className='text-foreground text-2xl font-semibold tracking-tight'>
-                  {t('Welcome to register · {{tenantName}}', {
-                    tenantName: '腾讯科技有限公司',
-                  })}
+                  {invitationTenantName
+                    ? t('Welcome to register · {{tenantName}}', {
+                        tenantName: invitationTenantName,
+                      })
+                    : t('Welcome')}
                 </h2>
               </div>
               <p className='text-muted-foreground text-sm leading-6'>

+ 2 - 0
default/src/features/auth/types.ts

@@ -26,6 +26,7 @@ export interface LoginPayload {
   username: string
   password: string
   turnstile?: string
+  tenant_unique_id?: string
 }
 
 export interface TwoFAPayload {
@@ -39,6 +40,7 @@ export interface RegisterPayload {
   verification_code?: string
   aff_code?: string
   turnstile?: string
+  tenant_unique_id?: string
 }
 
 export interface PasswordResetPayload {

+ 13 - 14
default/src/features/channels/components/channels-primary-buttons.tsx

@@ -48,11 +48,7 @@ import {
   TooltipContent,
   TooltipTrigger,
 } from '@/components/ui/tooltip'
-import {
-  ADMIN_PERMISSION_ACTIONS,
-  ADMIN_PERMISSION_RESOURCES,
-  hasPermission,
-} from '@/lib/admin-permissions'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
 import { useAuthStore } from '@/stores/auth-store'
 
 import {
@@ -79,10 +75,13 @@ export function ChannelsPrimaryButtons() {
   const [showConsistencyDialog, setShowConsistencyDialog] = useState(false)
   const [isRepairingConsistency, setIsRepairingConsistency] = useState(false)
   const currentUser = useAuthStore((s) => s.auth.user)
-  const canEditSensitive = hasPermission(
+  const canCreateChannel = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.CHANNEL_CREATE
+  )
+  const canDeleteChannel = hasPermissionCode(
     currentUser,
-    ADMIN_PERMISSION_RESOURCES.CHANNEL,
-    ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
+    PERMISSION_CODES.CHANNEL_DELETE
   )
 
   const handleTagModeToggle = (checked: boolean) => {
@@ -147,19 +146,19 @@ export function ChannelsPrimaryButtons() {
           <TooltipTrigger render={<span className='inline-flex' />}>
             <Button
               onClick={() => {
-                if (!canEditSensitive) return
+                if (!canCreateChannel) return
                 setCurrentRow(null)
                 setOpen('create-channel')
               }}
               size='sm'
-              disabled={!canEditSensitive}
+              disabled={!canCreateChannel}
             >
               <Plus className='h-4 w-4' />
               <span className='max-sm:hidden'>{t('Create Channel')}</span>
               <span className='sm:hidden'>{t('Create')}</span>
             </Button>
           </TooltipTrigger>
-          {!canEditSensitive && (
+          {!canCreateChannel && (
             <TooltipContent>
               {t('No permission to perform this action')}
             </TooltipContent>
@@ -230,10 +229,10 @@ export function ChannelsPrimaryButtons() {
             <DropdownMenuItem
               onSelect={(e) => {
                 e.preventDefault()
-                if (!canEditSensitive) return
+                if (!canDeleteChannel) return
                 setShowDeleteDialog(true)
               }}
-              disabled={!canEditSensitive}
+              disabled={!canDeleteChannel}
               className='text-destructive focus:text-destructive'
             >
               {t('Delete All Disabled')}
@@ -254,7 +253,7 @@ export function ChannelsPrimaryButtons() {
         )}
         destructive
         handleConfirm={() => {
-          if (!canEditSensitive) return
+          if (!canDeleteChannel) return
           handleDeleteAllDisabled(queryClient, (_count) => {
             // eslint-disable-next-line no-console
             console.log(`Deleted ${_count} channels`)

+ 44 - 28
default/src/features/channels/components/data-table-row-actions.tsx

@@ -54,7 +54,9 @@ import {
 import {
   ADMIN_PERMISSION_ACTIONS,
   ADMIN_PERMISSION_RESOURCES,
+  PERMISSION_CODES,
   hasPermission,
+  hasPermissionCode,
 } from '@/lib/admin-permissions'
 import { useAuthStore } from '@/stores/auth-store'
 
@@ -94,6 +96,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
     ADMIN_PERMISSION_RESOURCES.CHANNEL,
     ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
   )
+  const canUpdateChannel = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.CHANNEL_UPDATE
+  )
+  const canDeleteChannel = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.CHANNEL_DELETE
+  )
+  const canDisableChannel = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.CHANNEL_DISABLE
+  )
 
   const handleEdit = () => {
     setCurrentRow(channel)
@@ -158,7 +172,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
 
   return (
     <div className='-ml-1.5 flex items-center gap-1'>
-      {layout !== 'card' && (
+      {layout !== 'card' && canUpdateChannel && (
         <Tooltip>
           <TooltipTrigger
             render={
@@ -221,29 +235,31 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
         </Tooltip>
       )}
 
-      <Tooltip>
-        <TooltipTrigger
-          render={
-            <Button
-              variant='ghost'
-              size='icon-sm'
-              onClick={handleToggleStatus}
-              disabled={isTogglingStatus}
-              aria-label={isEnabled ? t('Disable') : t('Enable')}
-              className={
-                isEnabled
-                  ? 'text-destructive hover:text-destructive'
-                  : 'text-success hover:text-success'
-              }
-            />
-          }
-        >
-          {statusIcon}
-        </TooltipTrigger>
-        <TooltipContent>
-          {isEnabled ? t('Disable') : t('Enable')}
-        </TooltipContent>
-      </Tooltip>
+      {canDisableChannel && (
+        <Tooltip>
+          <TooltipTrigger
+            render={
+              <Button
+                variant='ghost'
+                size='icon-sm'
+                onClick={handleToggleStatus}
+                disabled={isTogglingStatus}
+                aria-label={isEnabled ? t('Disable') : t('Enable')}
+                className={
+                  isEnabled
+                    ? 'text-destructive hover:text-destructive'
+                    : 'text-success hover:text-success'
+                }
+              />
+            }
+          >
+            {statusIcon}
+          </TooltipTrigger>
+          <TooltipContent>
+            {isEnabled ? t('Disable') : t('Enable')}
+          </TooltipContent>
+        </Tooltip>
+      )}
 
       <DropdownMenu>
         <DropdownMenuTrigger
@@ -258,7 +274,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
           <span className='sr-only'>{t('Open menu')}</span>
         </DropdownMenuTrigger>
         <DropdownMenuContent align='end' className='w-48'>
-          {layout === 'card' && (
+          {layout === 'card' && canUpdateChannel && (
             <DropdownMenuItem onClick={handleEdit}>
               {t('Edit')}
               <DropdownMenuShortcut>
@@ -362,10 +378,10 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
 
           {/* Delete */}
           <DropdownMenuItem
-            disabled={!canEditSensitive}
+            disabled={!canDeleteChannel}
             onSelect={(e) => {
               e.preventDefault()
-              if (!canEditSensitive) return
+              if (!canDeleteChannel) return
               setDeleteConfirmOpen(true)
             }}
             className='text-destructive focus:text-destructive'
@@ -389,7 +405,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
         confirmText={t('Delete')}
         destructive
         handleConfirm={() => {
-          if (!canEditSensitive) return
+          if (!canDeleteChannel) return
           handleDeleteChannel(channel.id, queryClient)
           setDeleteConfirmOpen(false)
         }}

+ 38 - 38
default/src/features/channels/constants.ts

@@ -23,60 +23,60 @@ For commercial licensing, please contact support@quantumnous.com
 
 export const CHANNEL_TYPES = {
   0: 'Unknown',
-  1: 'OpenAI',
-  2: 'MjProxy',
-  3: 'Azure',
+  // 1: 'OpenAI',
+  // 2: 'MjProxy',
+  // 3: 'Azure',
   4: 'Ollama',
-  5: 'MjProxyPlus',
+  // 5: 'MjProxyPlus',
   // 6: 'OpenAIMax',
-  7: 'OhMyGPT',
+  // 7: 'OhMyGPT',
   8: 'Custom',
   // 9: 'AILS',
   // 10: 'AI Proxy',
   // 11: 'PaLM',
   // 12: 'API2GPT',
   // 13: 'AIGC2D',
-  14: 'Anthropic',
-  15: 'Baidu',
+  // 14: 'Anthropic',
+  // 15: 'Baidu',
   16: 'Zhipu',
   17: 'Ali',
-  18: 'Xunfei',
-  19: '360',
-  20: 'OpenRouter',
+  // 18: 'Xunfei',
+  // 19: '360',
+  // 20: 'OpenRouter',
   // 21: 'AI Proxy Library',
-  22: 'FastGPT',
-  23: 'Tencent',
-  24: 'Gemini',
+  // 22: 'FastGPT',
+  // 23: 'Tencent',
+  // 24: 'Gemini',
   25: 'Moonshot',
   26: 'Zhipu V4',
-  27: 'Perplexity',
-  31: 'LingYiWanWu',
-  33: 'AWS',
-  34: 'Cohere',
+  // 27: 'Perplexity',
+  // 31: 'LingYiWanWu',
+  // 33: 'AWS',
+  // 34: 'Cohere',
   35: 'MiniMax',
-  36: 'SunoAPI',
-  37: 'Dify',
-  38: 'Jina',
-  39: 'Cloudflare',
-  40: 'SiliconFlow',
-  41: 'Vertex AI',
-  42: 'Mistral',
+  // 36: 'SunoAPI',
+  // 37: 'Dify',
+  // 38: 'Jina',
+  // 39: 'Cloudflare',
+  // 40: 'SiliconFlow',
+  // 41: 'Vertex AI',
+  // 42: 'Mistral',
   43: 'DeepSeek',
-  44: 'MokaAI',
-  45: 'VolcEngine',
-  46: 'Baidu V2',
+  // 44: 'MokaAI',
+  // 45: 'VolcEngine',
+  // 46: 'Baidu V2',
   47: 'Xinference',
-  48: 'xAI',
-  49: 'Coze',
-  50: 'Kling',
-  51: 'Jimeng',
-  52: 'Vidu',
-  53: 'Submodel',
-  54: 'DoubaoVideo',
-  55: 'Sora',
-  56: 'Replicate',
-  57: 'ChatGPT Subscription (Codex)',
-  58: 'Advanced Custom',
+  // 48: 'xAI',
+  // 49: 'Coze',
+  // 50: 'Kling',
+  // 51: 'Jimeng',
+  // 52: 'Vidu',
+  // 53: 'Submodel',
+  // 54: 'DoubaoVideo',
+  // 55: 'Sora',
+  // 56: 'Replicate',
+  // 57: 'ChatGPT Subscription (Codex)',
+  // 58: 'Advanced Custom',
 } as const
 
 const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [

+ 1 - 1
default/src/features/channels/lib/channel-form.ts

@@ -300,7 +300,7 @@ export type ChannelFormValues = z.infer<typeof channelFormSchema>
 
 export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
   name: '',
-  type: 1,
+  type: 8,
   base_url: '',
   key: '',
   openai_organization: '',

+ 7 - 0
default/src/features/keys/components/api-keys-primary-buttons.tsx

@@ -20,12 +20,19 @@ import { Plus } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 
 import { Button } from '@/components/ui/button'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
 
 import { useApiKeys } from './api-keys-provider'
 
 export function ApiKeysPrimaryButtons() {
   const { t } = useTranslation()
   const { setOpen } = useApiKeys()
+  const currentUser = useAuthStore((s) => s.auth.user)
+  const canCreateKey = hasPermissionCode(currentUser, PERMISSION_CODES.KEYS_CREATE)
+
+  if (!canCreateKey) return null
+
   return (
     <div className='flex gap-2'>
       <Button size='sm' onClick={() => setOpen('create')}>

+ 67 - 53
default/src/features/keys/components/data-table-row-actions.tsx

@@ -44,6 +44,8 @@ import {
 } from '@/components/ui/tooltip'
 import { encodeChannelConnectionInfo } from '@/lib/channel-connection-info'
 import { copyToClipboard } from '@/lib/copy-to-clipboard'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
 
 import { updateApiKeyStatus } from '../api'
 import { API_KEY_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
@@ -84,6 +86,10 @@ export function DataTableRowActions<TData>({
   const [isTogglingStatus, setIsTogglingStatus] = useState(false)
   const resolvedRealKey = resolvedKeys[apiKey.id]
   const isRealKeyLoading = Boolean(loadingKeys[apiKey.id])
+  const currentUser = useAuthStore((s) => s.auth.user)
+  const canUpdateKey = hasPermissionCode(currentUser, PERMISSION_CODES.KEYS_UPDATE)
+  const canDeleteKey = hasPermissionCode(currentUser, PERMISSION_CODES.KEYS_DELETE)
+  const canDisableKey = hasPermissionCode(currentUser, PERMISSION_CODES.KEYS_DISABLE)
 
   const toggleLabel = isEnabled ? t('Disable') : t('Enable')
 
@@ -139,46 +145,50 @@ export function DataTableRowActions<TData>({
 
   return (
     <div className='-ml-1.5 flex items-center gap-1'>
-      <Tooltip>
-        <TooltipTrigger
-          render={
-            <Button
-              variant='ghost'
-              size='icon-sm'
-              onClick={handleToggleStatus}
-              disabled={isTogglingStatus}
-              aria-label={toggleLabel}
-              className={
-                isEnabled
-                  ? 'text-destructive hover:text-destructive'
-                  : 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
-              }
-            />
-          }
-        >
-          {statusIcon}
-        </TooltipTrigger>
-        <TooltipContent>{toggleLabel}</TooltipContent>
-      </Tooltip>
-
-      <Tooltip>
-        <TooltipTrigger
-          render={
-            <Button
-              variant='ghost'
-              size='icon-sm'
-              onClick={() => {
-                setCurrentRow(apiKey)
-                setOpen('update')
-              }}
-              aria-label={t('Edit')}
-            />
-          }
-        >
-          <Edit />
-        </TooltipTrigger>
-        <TooltipContent>{t('Edit')}</TooltipContent>
-      </Tooltip>
+      {canDisableKey && (
+        <Tooltip>
+          <TooltipTrigger
+            render={
+              <Button
+                variant='ghost'
+                size='icon-sm'
+                onClick={handleToggleStatus}
+                disabled={isTogglingStatus}
+                aria-label={toggleLabel}
+                className={
+                  isEnabled
+                    ? 'text-destructive hover:text-destructive'
+                    : 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
+                }
+              />
+            }
+          >
+            {statusIcon}
+          </TooltipTrigger>
+          <TooltipContent>{toggleLabel}</TooltipContent>
+        </Tooltip>
+      )}
+
+      {canUpdateKey && (
+        <Tooltip>
+          <TooltipTrigger
+            render={
+              <Button
+                variant='ghost'
+                size='icon-sm'
+                onClick={() => {
+                  setCurrentRow(apiKey)
+                  setOpen('update')
+                }}
+                aria-label={t('Edit')}
+              />
+            }
+          >
+            <Edit />
+          </TooltipTrigger>
+          <TooltipContent>{t('Edit')}</TooltipContent>
+        </Tooltip>
+      )}
 
       <DataTableRowActionMenu
         ariaLabel={t('Open menu')}
@@ -216,19 +226,23 @@ export function DataTableRowActions<TData>({
             <Link size={16} />
           </DropdownMenuShortcut>
         </DropdownMenuItem>
-        <DropdownMenuSeparator />
-        <DropdownMenuItem
-          onClick={() => {
-            setCurrentRow(apiKey)
-            setOpen('delete')
-          }}
-          className='text-destructive focus:text-destructive'
-        >
-          {t('Delete')}
-          <DropdownMenuShortcut>
-            <Trash2 size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
+        {canDeleteKey && (
+          <>
+            <DropdownMenuSeparator />
+            <DropdownMenuItem
+              onClick={() => {
+                setCurrentRow(apiKey)
+                setOpen('delete')
+              }}
+              className='text-destructive focus:text-destructive'
+            >
+              {t('Delete')}
+              <DropdownMenuShortcut>
+                <Trash2 size={16} />
+              </DropdownMenuShortcut>
+            </DropdownMenuItem>
+          </>
+        )}
       </DataTableRowActionMenu>
     </div>
   )

+ 73 - 51
default/src/features/models/components/data-table-row-actions.tsx

@@ -34,6 +34,8 @@ import {
   TooltipContent,
   TooltipTrigger,
 } from '@/components/ui/tooltip'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
 
 import {
   handleDeleteModel,
@@ -53,8 +55,21 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
   const { setOpen, setCurrentRow } = useModels()
   const queryClient = useQueryClient()
   const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
+  const currentUser = useAuthStore((s) => s.auth.user)
 
   const isEnabled = isModelEnabled(model)
+  const canUpdateModel = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.MODELS_UPDATE
+  )
+  const canDeleteModel = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.MODELS_DELETE
+  )
+  const canDisableModel = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.MODELS_DISABLE
+  )
 
   const handleEdit = () => {
     setCurrentRow(model)
@@ -69,57 +84,63 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
 
   return (
     <div className='-ml-1.5 flex items-center gap-1'>
-      <Tooltip>
-        <TooltipTrigger
-          render={
-            <Button
-              variant='ghost'
-              size='icon-sm'
-              onClick={handleEdit}
-              aria-label={t('Edit')}
-            />
-          }
-        >
-          <Pencil />
-        </TooltipTrigger>
-        <TooltipContent>{t('Edit')}</TooltipContent>
-      </Tooltip>
-
-      <Tooltip>
-        <TooltipTrigger
-          render={
-            <Button
-              variant='ghost'
-              size='icon-sm'
-              onClick={handleToggleStatus}
-              aria-label={toggleLabel}
-              className={
-                isEnabled
-                  ? 'text-destructive hover:text-destructive'
-                  : 'text-success hover:text-success'
-              }
-            />
-          }
-        >
-          {isEnabled ? <PowerOff /> : <Power />}
-        </TooltipTrigger>
-        <TooltipContent>{toggleLabel}</TooltipContent>
-      </Tooltip>
-
-      <DataTableRowActionMenu ariaLabel={t('Open menu')}>
-        <DropdownMenuItem
-          onSelect={(e) => {
-            e.preventDefault()
-            setDeleteConfirmOpen(true)
-          }}
-          className='text-destructive focus:text-destructive'
-        >
-          {t('Delete')}
-          <DropdownMenuShortcut>
-            <Trash2 size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-      </DataTableRowActionMenu>
+      {canUpdateModel && (
+        <Tooltip>
+          <TooltipTrigger
+            render={
+              <Button
+                variant='ghost'
+                size='icon-sm'
+                onClick={handleEdit}
+                aria-label={t('Edit')}
+              />
+            }
+          >
+            <Pencil />
+          </TooltipTrigger>
+          <TooltipContent>{t('Edit')}</TooltipContent>
+        </Tooltip>
+      )}
+
+      {canDisableModel && (
+        <Tooltip>
+          <TooltipTrigger
+            render={
+              <Button
+                variant='ghost'
+                size='icon-sm'
+                onClick={handleToggleStatus}
+                aria-label={toggleLabel}
+                className={
+                  isEnabled
+                    ? 'text-destructive hover:text-destructive'
+                    : 'text-success hover:text-success'
+                }
+              />
+            }
+          >
+            {isEnabled ? <PowerOff /> : <Power />}
+          </TooltipTrigger>
+          <TooltipContent>{toggleLabel}</TooltipContent>
+        </Tooltip>
+      )}
+
+      {canDeleteModel && (
+        <DataTableRowActionMenu ariaLabel={t('Open menu')}>
+          <DropdownMenuItem
+            onSelect={(e) => {
+              e.preventDefault()
+              setDeleteConfirmOpen(true)
+            }}
+            className='text-destructive focus:text-destructive'
+          >
+            {t('Delete')}
+            <DropdownMenuShortcut>
+              <Trash2 size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        </DataTableRowActionMenu>
+      )}
 
       <ConfirmDialog
         open={deleteConfirmOpen}
@@ -132,6 +153,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
         confirmText={t('Delete')}
         destructive
         handleConfirm={() => {
+          if (!canDeleteModel) return
           handleDeleteModel(model.id, queryClient)
           setDeleteConfirmOpen(false)
         }}

+ 16 - 5
default/src/features/models/components/models-columns.tsx

@@ -32,7 +32,9 @@ import {
   TooltipTrigger,
 } from '@/components/ui/tooltip'
 import { formatTimestampToDate } from '@/lib/format'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
 import { getLobeIcon } from '@/lib/lobe-icon'
+import { useAuthStore } from '@/stores/auth-store'
 
 import {
   getModelStatusConfig,
@@ -55,6 +57,11 @@ function getCompactModelIcon(iconKey: string) {
  */
 export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
   const { t } = useTranslation()
+  const currentUser = useAuthStore((state) => state.auth.user)
+  const hasActions =
+    hasPermissionCode(currentUser, PERMISSION_CODES.MODELS_UPDATE) ||
+    hasPermissionCode(currentUser, PERMISSION_CODES.MODELS_DELETE) ||
+    hasPermissionCode(currentUser, PERMISSION_CODES.MODELS_DISABLE)
 
   // Get translated configs
   const NAME_RULE_CONFIG = getNameRuleConfig(t)
@@ -66,7 +73,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
     vendorMap[v.id] = v
   })
 
-  return [
+  const columns: ColumnDef<Model>[] = [
     // Checkbox column
     {
       id: 'select',
@@ -463,8 +470,10 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
       size: 140,
     },
 
-    // Actions column
-    {
+  ]
+
+  if (hasActions) {
+    columns.push({
       id: 'actions',
       header: () => t('Actions'),
       cell: ({ row }) => {
@@ -473,6 +482,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
       enableSorting: false,
       enableHiding: false,
       meta: { pinned: 'right' as const },
-    },
-  ]
+    })
+  }
+
+  return columns
 }

+ 13 - 4
default/src/features/models/components/models-primary-buttons.tsx

@@ -35,12 +35,19 @@ import {
   DropdownMenuShortcut,
   DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
 
 import { useModels } from './models-provider'
 
 export function ModelsPrimaryButtons() {
   const { t } = useTranslation()
   const { setOpen, setCurrentRow } = useModels()
+  const currentUser = useAuthStore((s) => s.auth.user)
+  const canCreateModel = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.MODELS_CREATE
+  )
 
   const handleCreateModel = () => {
     setCurrentRow(null)
@@ -66,10 +73,12 @@ export function ModelsPrimaryButtons() {
   return (
     <div className='flex items-center gap-2'>
       {/* Create Model */}
-      <Button onClick={handleCreateModel} size='sm'>
-        <Plus className='h-4 w-4' />
-        {t('Add Model')}
-      </Button>
+      {canCreateModel && (
+        <Button onClick={handleCreateModel} size='sm'>
+          <Plus className='h-4 w-4' />
+          {t('Add Model')}
+        </Button>
+      )}
 
       {/* More Actions */}
       <DropdownMenu>

+ 155 - 0
default/src/features/permissions/api.ts

@@ -0,0 +1,155 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { api } from '@/lib/api'
+
+export interface PermissionItem {
+  id?: number | string
+  key?: string
+  code?: string
+  module_code?: string
+  module_desc?: string
+  permission_key?: string
+  permission_code?: string
+  front_perm_code?: string
+  api_perm_code?: string
+  name?: string
+  title?: string
+  label?: string
+  module_name?: string
+  module_label?: string
+  perm_name?: string
+  perm_label?: string
+  perm_desc?: string
+  description?: string
+  module?: string
+  module_key?: string
+  resource?: string
+  action?: string
+  action_key?: string
+  action_name?: string
+  has_permission?: boolean
+  checked?: boolean
+  selected?: boolean
+  children?: PermissionItem[]
+  actions?: PermissionItem[]
+  permissions?: PermissionItem[]
+  ExtPermission?: PermissionItem | null
+  module_relation_permissions?: PermissionItem[]
+}
+
+export type CurrentPermissionSnapshot = {
+  codes: string[]
+}
+
+interface PermissionApiResponse {
+  success?: boolean
+  message?: string
+  data?:
+    | PermissionItem[]
+    | {
+        items?: PermissionItem[]
+        permissions?: PermissionItem[]
+        list?: PermissionItem[]
+      }
+}
+
+function normalizePermissionList(resData: PermissionApiResponse | PermissionItem[]) {
+  if (Array.isArray(resData)) return resData
+  if (Array.isArray(resData.data)) return resData.data
+  return (
+    resData.data?.items ??
+    resData.data?.permissions ??
+    resData.data?.list ??
+    []
+  )
+}
+
+function permissionHasRole(permission: PermissionItem) {
+  return permission.has_permission ?? permission.checked ?? permission.selected ?? false
+}
+
+function getPermissionCode(permission: PermissionItem) {
+  return (
+    permission.front_perm_code ??
+    permission.permission_key ??
+    permission.code ??
+    permission.permission_code ??
+    permission.module_code ??
+    permission.key ??
+    permission.action_key ??
+    permission.action ??
+    (permission.id === undefined ? undefined : String(permission.id))
+  )
+}
+
+function collectPermissionCodes(
+  permissions: PermissionItem[],
+  codes = new Set<string>()
+) {
+  for (const permission of permissions) {
+    const childPermissions =
+      permission.module_relation_permissions ??
+      permission.children ??
+      permission.actions ??
+      permission.permissions ??
+      []
+    const beforeSize = codes.size
+
+    for (const relation of childPermissions) {
+      const target = relation.ExtPermission ?? relation
+      if (permissionHasRole(relation) || permissionHasRole(target)) {
+        const code = getPermissionCode(target)
+        if (code) codes.add(code)
+      }
+      collectPermissionCodes([target], codes)
+    }
+
+    if (permissionHasRole(permission) || codes.size > beforeSize) {
+      const code = getPermissionCode(permission)
+      if (code) codes.add(code)
+    }
+  }
+
+  return codes
+}
+
+export function toCurrentPermissionSnapshot(
+  permissions: PermissionItem[]
+): CurrentPermissionSnapshot {
+  return {
+    codes: [...collectPermissionCodes(permissions)],
+  }
+}
+
+export async function getPermissions(): Promise<PermissionItem[]> {
+  const res = await api.get<PermissionApiResponse | PermissionItem[]>('/api/permissions')
+  return normalizePermissionList(res.data)
+}
+
+export async function getPermissionsWithRole(roleId?: number | string): Promise<PermissionItem[]> {
+  const res = await api.get<PermissionApiResponse | PermissionItem[]>(
+    '/api/permissions/withRole',
+    roleId ? { params: { role_id: roleId } } : undefined
+  )
+  return normalizePermissionList(res.data)
+}
+
+export async function getCurrentPermissionsWithRole(): Promise<CurrentPermissionSnapshot> {
+  return toCurrentPermissionSnapshot(await getPermissionsWithRole())
+}

+ 136 - 8
default/src/features/roles/api.ts

@@ -16,22 +16,145 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 
 For commercial licensing, please contact support@quantumnous.com
 */
-import { api } from '@/lib/api'
+import { api, type ApiRequestConfig } from '@/lib/api'
 
 import type {
   Role,
+  RawRole,
   GetRolesResponse,
   SearchRolesParams,
   RoleFormData,
+  CreateRolePayload,
   ApiResponse,
 } from './types'
 
+interface RawRoleResponse extends Omit<ApiResponse<Role>, 'data'> {
+  data?: RawRole
+}
+
+interface RawGetRolesResponse extends Omit<GetRolesResponse, 'data'> {
+  data?: {
+    items: RawRole[]
+    total: number
+    page?: number
+    page_size?: number
+  }
+}
+
+function addPermissionCode(
+  permissions: Record<string, string[]>,
+  permissionCode: string
+) {
+  const [moduleCode, actionCode] = permissionCode.split('.', 2)
+  const selectedPermissions = permissions[moduleCode] ?? []
+  permissions[moduleCode] = [...new Set([
+	...selectedPermissions,
+	moduleCode,
+	permissionCode,
+	actionCode ?? permissionCode
+])]
+}
+
+function normalizeRole(role: RawRole): Role {
+  const permissions: Record<string, string[]> = {}
+
+  for (const permissionCode of role.permission_codes ?? []) {
+    addPermissionCode(permissions, permissionCode)
+  }
+
+  for (const permission of role.role_relation_permissions ?? []) {
+    const resource = permission.resource ?? permission.ext_permission?.resource
+    const action = permission.action ?? permission.ext_permission?.action
+    const permissionKey =
+      permission.permission_key ?? permission.ext_permission?.permission_key
+    const permissionCode =
+      permission.permission_code ?? permission.ext_permission?.permission_code
+
+    if (resource && action) {
+      addPermissionCode(permissions, `${resource}.${action}`)
+      continue
+    }
+
+    if (permissionKey || permissionCode) {
+      const key = permissionKey ?? permissionCode ?? ''
+      addPermissionCode(permissions, key)
+    }
+  }
+
+  return {
+    id: role.id,
+    name: role.role_name,
+    description: role.role_desc,
+    role_name: role.role_name,
+    role_desc: role.role_desc,
+    tenant_id: role.tenant_id,
+    type: role.type,
+    is_can_edit: role.is_can_edit,
+    permission_modules_count: role.permission_module_count ?? 0,
+    permission_actions_count:
+      role.permission_count ??
+      role.permission_codes?.length ??
+      role.role_relation_permissions?.length ??
+      0,
+    permissions,
+    permission_codes: role.permission_codes,
+    created_at: role.created_at,
+    updated_at: role.updated_at,
+  }
+}
+
+function normalizeGetRolesResponse(response: RawGetRolesResponse): GetRolesResponse {
+  return {
+    ...response,
+    data: response.data
+      ? {
+          ...response.data,
+          items: response.data.items.map(normalizeRole),
+        }
+      : undefined,
+  }
+}
+
+function normalizePermissionCode(moduleCode: string, permissionCode: string) {
+  if (permissionCode.includes('.') || permissionCode.includes(':')) return permissionCode
+  return `${moduleCode}.${permissionCode}`
+}
+
+function getPermissionCodes(permissions: RoleFormData['permissions']) {
+  if (!permissions) return []
+
+  return [
+    ...new Set(
+      Object.entries(permissions).flatMap(([moduleCode, permissionCodes]) => {
+        const selectedPermissionCodes = permissionCodes.filter(
+          (permissionCode) =>
+            permissionCode !== moduleCode && !permissionCode.startsWith('route.')
+        )
+        return selectedPermissionCodes.map((permissionCode) =>
+          normalizePermissionCode(moduleCode, permissionCode)
+        )
+      })
+    ),
+  ]
+}
+
+function toCreateRolePayload(data: RoleFormData): CreateRolePayload {
+  const permissionCodes = getPermissionCodes(data.permissions)
+  return {
+    role_name: data.name,
+    role_desc: data.description || undefined,
+    permission_codes: permissionCodes.length ? permissionCodes : undefined,
+  }
+}
+
 export async function getRoles(
   params: { p?: number; page_size?: number } = {}
 ): Promise<GetRolesResponse> {
   const { p = 1, page_size = 10 } = params
-  const res = await api.get(`/api/role/?p=${p}&page_size=${page_size}`)
-  return res.data
+  const res = await api.get<RawGetRolesResponse>('/api/roles/', {
+    params: { p, page_size },
+  })
+  return normalizeGetRolesResponse(res.data)
 }
 
 export async function searchRoles(
@@ -47,12 +170,15 @@ export async function searchRoles(
 }
 
 export async function getRole(id: number): Promise<ApiResponse<Role>> {
-  const res = await api.get(`/api/role/${id}`)
-  return res.data
+  const res = await api.get<RawRoleResponse>(`/api/roles/${id}`)
+  return {
+    ...res.data,
+    data: res.data.data ? normalizeRole(res.data.data) : undefined,
+  }
 }
 
 export async function createRole(data: RoleFormData): Promise<ApiResponse<Role>> {
-  const res = await api.post('/api/role/', data)
+  const res = await api.post('/api/roles/', toCreateRolePayload(data))
   return res.data
 }
 
@@ -60,11 +186,13 @@ export async function updateRole(
   id: number,
   data: RoleFormData
 ): Promise<ApiResponse<Role>> {
-  const res = await api.put(`/api/role/${id}`, data)
+  const res = await api.put(`/api/roles/${id}`, toCreateRolePayload(data))
   return res.data
 }
 
 export async function deleteRole(id: number): Promise<ApiResponse> {
-  const res = await api.delete(`/api/role/${id}/`)
+  const res = await api.delete(`/api/roles/${id}`, {
+    skipBusinessError: true,
+  } as ApiRequestConfig)
   return res.data
 }

+ 482 - 214
default/src/features/roles/index.tsx

@@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
 */
 import { useQuery } from '@tanstack/react-query'
 import { Pencil, Trash2, Plus } from 'lucide-react'
-import type { MouseEvent } from 'react'
 import { useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
@@ -28,7 +27,16 @@ import {
   useDataTable,
 } from '@/components/data-table'
 import { SectionPageLayout } from '@/components/layout'
+import {
+  SideDrawerSection,
+  sideDrawerContentClassName,
+  sideDrawerFooterClassName,
+  sideDrawerFormClassName,
+  sideDrawerHeaderClassName,
+} from '@/components/drawer-layout'
 import { Button } from '@/components/ui/button'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
 import { Checkbox } from '@/components/ui/checkbox'
 import { Input } from '@/components/ui/input'
 import {
@@ -42,8 +50,10 @@ import {
 } from '@/components/ui/dialog'
 import {
   Sheet,
+  SheetClose,
   SheetContent,
   SheetDescription,
+  SheetFooter,
   SheetHeader,
   SheetTitle,
   SheetTrigger,
@@ -66,16 +76,27 @@ import type { ColumnDef } from '@tanstack/react-table'
 
 import {
   getRoles,
-  searchRoles,
+  getRole,
   createRole,
   updateRole,
   deleteRole,
 } from './api'
+import { getPermissions, type PermissionItem } from '../permissions/api'
 import type { Role } from './types'
 
 type RoleFormValues = {
   name: string
   description: string
+  permissions: Record<string, string[]>
+}
+
+type RolePermissionModule = {
+  id?: number
+  key: string
+  title: string
+  description: string
+  viewPermissionKey?: string
+  actions: { id?: number; key: string; label: string }[]
 }
 
 const MOCK_ROLE_BASE_TIME = 1_725_523_200
@@ -103,36 +124,231 @@ const rolePermissionModules = [
     key: 'channels',
     title: 'Channel Management',
     description: 'Manage channel list, test channels, and update channel settings',
-    actions: ['View', 'Create', 'Edit', 'Delete'],
+    actions: [
+      { key: 'view', label: 'View' },
+      { key: 'create', label: 'Create' },
+      { key: 'edit', label: 'Edit' },
+      { key: 'delete', label: 'Delete' },
+    ],
   },
   {
     key: 'models',
     title: 'Model Management',
     description: 'Manage model metadata, pricing, and upstream model mappings',
-    actions: ['View', 'Create', 'Edit', 'Delete'],
+    actions: [
+      { key: 'view', label: 'View' },
+      { key: 'create', label: 'Create' },
+      { key: 'edit', label: 'Edit' },
+      { key: 'delete', label: 'Delete' },
+    ],
   },
   {
     key: 'users',
     title: 'User Management',
     description: 'Manage platform users, quotas, groups, and user roles',
-    actions: ['View', 'Create', 'Edit', 'Delete'],
+    actions: [
+      { key: 'view', label: 'View' },
+      { key: 'create', label: 'Create' },
+      { key: 'edit', label: 'Edit' },
+      { key: 'delete', label: 'Delete' },
+    ],
   },
   {
     key: 'finance',
     title: 'Finance Management',
     description: 'View wallet, billing, top-up, redemption, and financial records',
-    actions: ['View', 'Export', 'Audit'],
+    actions: [
+      { key: 'view', label: 'View' },
+      { key: 'export', label: 'Export' },
+      { key: 'audit', label: 'Audit' },
+    ],
   },
-]
+] satisfies RolePermissionModule[]
+
+function getPermissionKey(permission: PermissionItem) {
+  return (
+    permission.key ??
+    permission.permission_key ??
+    permission.code ??
+    permission.module_code ??
+    permission.front_perm_code ??
+    permission.permission_code ??
+    permission.action ??
+    permission.action_key ??
+    String(permission.id ?? '')
+  )
+}
+
+function getPermissionResourceKey(permission: PermissionItem) {
+  const key = getPermissionKey(permission)
+  if (key.startsWith('route.')) return key
+  return key.includes('.') ? key.split('.')[0] : key
+}
+
+function getPermissionModuleKey(
+  permission: PermissionItem,
+  actions: RolePermissionModule['actions']
+) {
+  const actionKey = actions.find((action) => action.key.includes('.'))?.key
+  if (actionKey) return actionKey.split('.')[0]
+
+  const key = getPermissionKey(permission)
+  if (key.startsWith('route.')) {
+    const parts = key.split('.')
+    return parts[1] ?? key
+  }
+
+  return getPermissionResourceKey(permission)
+}
+
+function getViewPermissionKey(module: RolePermissionModule) {
+  const viewAction = module.actions.find((action) =>
+    action.key.endsWith('.view') || action.key === 'view'
+  )
+  return viewAction?.key ?? module.viewPermissionKey ?? `${module.key}.view`
+}
+
+function isRolePermissionModule(module: RolePermissionModule) {
+  return module.key === 'role' || module.key === 'roles'
+}
+
+function isPermissionActionSelectable(
+  module: RolePermissionModule,
+  action: RolePermissionModule['actions'][number]
+) {
+  if (!isRolePermissionModule(module)) return true
+
+  const actionKey = action.key.split('.').at(-1)?.toLowerCase() ?? action.key.toLowerCase()
+  const actionLabel = action.label.toLowerCase()
+
+  return !['create', 'add', 'edit', 'update', 'delete'].some(
+    (disabledAction) => actionKey === disabledAction || actionLabel === disabledAction
+  )
+}
+
+function sanitizePermissionValues(
+  permissions: RoleFormValues['permissions'],
+  modules: RolePermissionModule[]
+) {
+  return modules.reduce<RoleFormValues['permissions']>((nextPermissions, module) => {
+    const selectableActions = new Set(
+      module.actions
+        .filter((action) => isPermissionActionSelectable(module, action))
+        .map((action) => action.key)
+    )
+    nextPermissions[module.key] = (permissions[module.key] ?? []).filter((permission) =>
+      selectableActions.has(permission)
+    )
+    return nextPermissions
+  }, {})
+}
+
+function getPermissionTitle(permission: PermissionItem, fallback: string) {
+  return (
+    permission.title ??
+    permission.module_label ??
+    permission.perm_label ??
+    permission.module_name ??
+    permission.perm_name ??
+    permission.name ??
+    permission.label ??
+    fallback
+  )
+}
+
+function getPermissionDescription(permission: PermissionItem) {
+  return permission.module_desc ?? permission.perm_desc ?? permission.description ?? ''
+}
+
+function getPermissionId(permission: PermissionItem) {
+  const numericId = Number(permission.id)
+  return Number.isFinite(numericId) ? numericId : undefined
+}
+
+function comparePermissionId(
+  left: { id?: number | string },
+  right: { id?: number | string }
+) {
+  return (
+    (getPermissionId(left as PermissionItem) ?? Number.MAX_SAFE_INTEGER) -
+    (getPermissionId(right as PermissionItem) ?? Number.MAX_SAFE_INTEGER)
+  )
+}
+
+function toRolePermissionModules(permissions: PermissionItem[]) {
+  if (!permissions.length) return rolePermissionModules
+
+  return [...permissions].sort(comparePermissionId).map((permission) => {
+    const actionsSource =
+      permission.module_relation_permissions ??
+      permission.actions ??
+      permission.children ??
+      permission.permissions ??
+      []
+    const actions = [...actionsSource]
+      .sort((left, right) =>
+        comparePermissionId(left.ExtPermission ?? left, right.ExtPermission ?? right)
+      )
+      .map((action) => {
+        const permissionAction = action.ExtPermission ?? action
+
+        return {
+          id: getPermissionId(permissionAction),
+          key: getPermissionKey(permissionAction),
+          label: getPermissionTitle(
+            permissionAction,
+            getPermissionKey(permissionAction)
+          ),
+        }
+      })
+      .filter((action) => action.key)
+    const key = getPermissionModuleKey(permission, actions)
+
+    return {
+      id: getPermissionId(permission),
+      key,
+      title: getPermissionTitle(permission, key),
+      description: getPermissionDescription(permission),
+      viewPermissionKey: getPermissionKey(permission),
+      actions,
+    }
+  }).filter((module) => module.key)
+}
 
 export function RolesSettings() {
   const { t } = useTranslation()
+  const currentUser = useAuthStore((s) => s.auth.user)
   const [searchValue, setSearchValue] = useState('')
   const [createOpen, setCreateOpen] = useState(false)
   const [editOpen, setEditOpen] = useState(false)
   const [deleteOpen, setDeleteOpen] = useState(false)
   const [currentRole, setCurrentRole] = useState<Role | null>(null)
   const [refreshTrigger, setRefreshTrigger] = useState(0)
+  const canCreateRole = hasPermissionCode(currentUser, PERMISSION_CODES.ROLE_CREATE)
+  const canUpdateRole = hasPermissionCode(currentUser, PERMISSION_CODES.ROLE_UPDATE)
+  const canDeleteRole = hasPermissionCode(currentUser, PERMISSION_CODES.ROLE_DELETE)
+  const hasActions = canUpdateRole || canDeleteRole
+
+  const fillRoleForm = async (role: Role) => {
+    setCurrentRole(role)
+    form.reset({
+      name: role.name,
+      description: role.description || '',
+      permissions: role.permissions ?? {},
+    })
+
+    try {
+      const result = await getRole(role.id)
+      if (!result.success || !result.data) return
+      setCurrentRole(result.data)
+      form.reset({
+        name: result.data.name,
+        description: result.data.description || '',
+        permissions: result.data.permissions ?? {},
+      })
+    } catch {
+    }
+  }
 
   // #region debug-point role-actions-open-state
   useEffect(() => {
@@ -149,6 +365,7 @@ export function RolesSettings() {
   const roleFormSchema = z.object({
     name: z.string().min(1, t('Role name is required')),
     description: z.string(),
+    permissions: z.record(z.string(), z.array(z.string())),
   })
 
   const form = useForm<RoleFormValues>({
@@ -156,6 +373,7 @@ export function RolesSettings() {
     defaultValues: {
       name: '',
       description: '',
+      permissions: {},
     },
   })
 
@@ -200,46 +418,13 @@ export function RolesSettings() {
 
   const [localRoles, setLocalRoles] = useState<Role[]>(mockRoles)
 
-  const handleOpenEdit = (role: Role, event?: MouseEvent<HTMLButtonElement>) => {
-    reportRoleActionsDebug('edit-click-start', {
-      roleId: role.id,
-      roleName: role.name,
-      eventType: event?.type,
-      defaultPrevented: event?.defaultPrevented,
-    })
-    event?.preventDefault()
-    event?.stopPropagation()
-    setCurrentRole(role)
-    form.reset({
-      name: role.name,
-      description: role.description || '',
-    })
-    setEditOpen(true)
-    reportRoleActionsDebug('edit-click-end', {
-      roleId: role.id,
-      roleName: role.name,
-    })
-  }
+  const permissionsQuery = useQuery({
+    queryKey: ['permissions', 'platform-role-form'],
+    queryFn: async () => toRolePermissionModules(await getPermissions()),
+    enabled: false,
+  })
 
-  const handleOpenDelete = (
-    role: Role,
-    event?: MouseEvent<HTMLButtonElement>
-  ) => {
-    reportRoleActionsDebug('delete-click-start', {
-      roleId: role.id,
-      roleName: role.name,
-      eventType: event?.type,
-      defaultPrevented: event?.defaultPrevented,
-    })
-    event?.preventDefault()
-    event?.stopPropagation()
-    setCurrentRole(role)
-    setDeleteOpen(true)
-    reportRoleActionsDebug('delete-click-end', {
-      roleId: role.id,
-      roleName: role.name,
-    })
-  }
+  const permissionModules = permissionsQuery.data ?? rolePermissionModules
 
   const columns: ColumnDef<Role>[] = [
     {
@@ -275,7 +460,7 @@ export function RolesSettings() {
         const count = row.getValue('permission_modules_count') as number
         return (
           <span className='inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700'>
-            {count}
+            {count}
           </span>
         )
       },
@@ -289,126 +474,128 @@ export function RolesSettings() {
         const count = row.getValue('permission_actions_count') as number
         return (
           <span className='inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700'>
-            {count}
+            {count}
           </span>
         )
       },
       size: 150,
     },
-    {
+  ]
+
+  if (hasActions) {
+    columns.push({
       id: 'actions',
       header: t('Actions'),
       cell: ({ row }) => {
         const role = row.original as Role
+        const isRootRole = role.type === 'root'
         return (
           <div className='flex items-center gap-2'>
-            <Sheet
-              open={editOpen && currentRole?.id === role.id}
-              onOpenChange={(open) => {
-                setEditOpen(open)
-                if (!open) return
-                setCurrentRole(role)
-                form.reset({
-                  name: role.name,
-                  description: role.description || '',
-                })
-              }}
-            >
-              <SheetTrigger render={<Button type='button' variant='ghost' size='sm' />}>
-                <Pencil className='mr-1 h-4 w-4' />
-                {t('Edit')}
-              </SheetTrigger>
-              <SheetContent className='w-full overflow-y-auto sm:max-w-2xl'>
-                <SheetHeader>
-                  <SheetTitle>{t('Edit Role')}</SheetTitle>
-                  <SheetDescription>
-                    {t('Update the role by providing necessary info.')}
-                  </SheetDescription>
-                </SheetHeader>
-                <div className='mt-6'>
-                  {renderRoleForm(() => setEditOpen(false), t('Save changes'))}
-                </div>
-              </SheetContent>
-            </Sheet>
-            <Dialog
-              open={deleteOpen && currentRole?.id === role.id}
-              onOpenChange={(open) => {
-                setDeleteOpen(open)
-                if (open) setCurrentRole(role)
-              }}
-            >
-              <DialogTrigger
-                render={
-                  <Button
-                    type='button'
-                    variant='ghost'
-                    size='sm'
-                    className='text-red-600 hover:bg-red-50 hover:text-red-700'
-                  />
-                }
+            {canUpdateRole && (
+              <Sheet
+                open={editOpen && currentRole?.id === role.id}
+                onOpenChange={(open) => {
+                  setEditOpen(open)
+                  if (!open) return
+                  void permissionsQuery.refetch()
+                  void fillRoleForm(role)
+                }}
               >
-                <Trash2 className='mr-1 h-4 w-4' />
-                {t('Delete')}
-              </DialogTrigger>
-              <DialogContent>
-                <DialogHeader>
-                  <DialogTitle>{t('Delete Role')}</DialogTitle>
-                  <DialogDescription>
-                    {t('Are you sure you want to delete role {{name}}?', {
-                      name: role.name,
-                    })}
-                  </DialogDescription>
-                </DialogHeader>
-                <DialogFooter>
-                  <Button variant='outline' onClick={() => setDeleteOpen(false)}>
-                    {t('Cancel')}
-                  </Button>
-                  <Button variant='destructive' onClick={handleDelete}>
-                    {t('Delete')}
-                  </Button>
-                </DialogFooter>
-              </DialogContent>
-            </Dialog>
+                <SheetTrigger
+                  render={
+                    <Button
+                      type='button'
+                      variant='ghost'
+                      size='sm'
+                      disabled={isRootRole}
+                    />
+                  }
+                >
+                  <Pencil className='mr-1 h-4 w-4' />
+                  {t('Edit')}
+                </SheetTrigger>
+                <SheetContent className={sideDrawerContentClassName('sm:max-w-2xl')}>
+                  <SheetHeader className={sideDrawerHeaderClassName()}>
+                    <SheetTitle>{t('Edit Role')}</SheetTitle>
+                    <SheetDescription>
+                      {t('Update the role by providing necessary info.')}
+                    </SheetDescription>
+                  </SheetHeader>
+                  {renderRoleForm('role-edit-form')}
+                  <SheetFooter className={sideDrawerFooterClassName()}>
+                    <SheetClose render={<Button type='button' variant='outline' />}>
+                      {t('Cancel')}
+                    </SheetClose>
+                    <Button form='role-edit-form' type='submit'>
+                      {t('Save changes')}
+                    </Button>
+                  </SheetFooter>
+                </SheetContent>
+              </Sheet>
+            )}
+            {canDeleteRole && (
+              <Dialog
+                open={deleteOpen && currentRole?.id === role.id}
+                onOpenChange={(open) => {
+                  setDeleteOpen(open)
+                  if (open) setCurrentRole(role)
+                }}
+              >
+                <DialogTrigger
+                  render={
+                    <Button
+                      type='button'
+                      variant='ghost'
+                      size='sm'
+                      disabled={isRootRole}
+                      className='text-red-600 hover:bg-red-50 hover:text-red-700'
+                    />
+                  }
+                >
+                  <Trash2 className='mr-1 h-4 w-4' />
+                  {t('Delete')}
+                </DialogTrigger>
+                <DialogContent>
+                  <DialogHeader>
+                    <DialogTitle>{t('Delete Role')}</DialogTitle>
+                    <DialogDescription>
+                      {t('Are you sure you want to delete role {{name}}?', {
+                        name: role.name,
+                      })}
+                    </DialogDescription>
+                  </DialogHeader>
+                  <DialogFooter>
+                    <Button variant='outline' onClick={() => setDeleteOpen(false)}>
+                      {t('Cancel')}
+                    </Button>
+                    <Button variant='destructive' onClick={handleDelete}>
+                      {t('Delete')}
+                    </Button>
+                  </DialogFooter>
+                </DialogContent>
+              </Dialog>
+            )}
           </div>
         )
       },
       size: 120,
-    },
-  ]
+    })
+  }
 
-  const { isLoading, isFetching } = useQuery({
-    queryKey: ['roles', searchValue, refreshTrigger],
+  const { data: rolesData, isLoading, isFetching } = useQuery({
+    queryKey: ['roles', refreshTrigger],
     queryFn: async () => {
-      const keyword = searchValue.trim().toLowerCase()
-      const getMockRoleData = () => {
-        const filteredRoles = keyword
-          ? localRoles.filter(
-              (role) =>
-                role.name.toLowerCase().includes(keyword) ||
-                role.description.toLowerCase().includes(keyword)
-            )
-          : localRoles
-
-        return { items: filteredRoles, total: filteredRoles.length }
-      }
-
       let result
 
       try {
-        result = searchValue.trim()
-          ? await searchRoles({ keyword: searchValue })
-          : await getRoles()
-      } catch (_error) {
-        return getMockRoleData()
+        result = await getRoles({ p: 1, page_size: 20 })
+      } catch {
+        return { items: localRoles, total: localRoles.length }
       }
 
       if (!result.success) {
         toast.error(result.message || t('Failed to load roles'))
-        return getMockRoleData()
-      }
-
-      if (!result.data?.items?.length) {
-        return getMockRoleData()
+        return { items: localRoles, total: localRoles.length }
       }
 
       return {
@@ -419,13 +606,14 @@ export function RolesSettings() {
   })
 
   const normalizedSearchValue = searchValue.trim().toLowerCase()
+  const roleItems = rolesData?.items ?? localRoles
   const roles = normalizedSearchValue
-    ? localRoles.filter(
+    ? roleItems.filter(
         (role) =>
           role.name.toLowerCase().includes(normalizedSearchValue) ||
           role.description.toLowerCase().includes(normalizedSearchValue)
       )
-    : localRoles
+    : roleItems
 
   const { table } = useDataTable({
     data: roles,
@@ -435,20 +623,25 @@ export function RolesSettings() {
   })
 
   const onSubmit = async (data: RoleFormValues) => {
+    const submitData = {
+      ...data,
+      permissions: sanitizePermissionValues(data.permissions, permissionModules),
+    }
+
     try {
       if (currentRole) {
         try {
-          const result = await updateRole(currentRole.id, data)
+          const result = await updateRole(currentRole.id, submitData)
           void result
-        } catch (_error) {
+        } catch {
         }
         setLocalRoles((prev) =>
           prev.map((role) =>
             role.id === currentRole.id
               ? {
                   ...role,
-                  name: data.name,
-                  description: data.description,
+                  name: submitData.name,
+                  description: submitData.description,
                   updated_at: Math.floor(Date.now() / 1000),
                 }
               : role
@@ -458,17 +651,20 @@ export function RolesSettings() {
         setEditOpen(false)
       } else {
         try {
-          const result = await createRole(data)
+          const result = await createRole(submitData)
           void result
-        } catch (_error) {
+        } catch {
         }
         setLocalRoles((prev) => [
           {
             id: Math.max(0, ...prev.map((role) => role.id)) + 1,
-            name: data.name,
-            description: data.description,
-            permission_modules_count: 0,
-            permission_actions_count: 0,
+            name: submitData.name,
+            description: submitData.description,
+            permission_modules_count: Object.keys(submitData.permissions).length,
+            permission_actions_count: Object.values(submitData.permissions).reduce(
+              (total, actions) => total + actions.length,
+              0
+            ),
             created_at: Math.floor(Date.now() / 1000),
             updated_at: Math.floor(Date.now() / 1000),
           },
@@ -478,7 +674,7 @@ export function RolesSettings() {
         setCreateOpen(false)
       }
       setRefreshTrigger((prev) => prev + 1)
-    } catch (_error) {
+    } catch {
       toast.error(t('An unexpected error occurred'))
     }
   }
@@ -486,10 +682,10 @@ export function RolesSettings() {
   const handleDelete = async () => {
     if (!currentRole) return
     try {
-      try {
-        const result = await deleteRole(currentRole.id)
-        void result
-      } catch (_error) {
+      const result = await deleteRole(currentRole.id)
+      if (!result.success) {
+        toast.error(result.message || t('An unexpected error occurred'))
+        return
       }
       setLocalRoles((prev) =>
         prev.filter((role) => role.id !== currentRole.id)
@@ -497,7 +693,7 @@ export function RolesSettings() {
       toast.success(t('Role deleted successfully'))
       setDeleteOpen(false)
       setRefreshTrigger((prev) => prev + 1)
-    } catch (_error) {
+    } catch {
       toast.error(t('An unexpected error occurred'))
     }
   }
@@ -506,14 +702,19 @@ export function RolesSettings() {
     setCreateOpen(open)
     if (open) {
       setCurrentRole(null)
-      form.reset({ name: '', description: '' })
+      form.reset({ name: '', description: '', permissions: {} })
+      void permissionsQuery.refetch()
     }
   }
 
-  const renderRoleForm = (onCancel: () => void, submitText: string) => (
+  const renderRoleForm = (formId: string) => (
     <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
-        <div className='rounded-lg border bg-card p-4'>
+      <form
+        id={formId}
+        onSubmit={form.handleSubmit(onSubmit)}
+        className={sideDrawerFormClassName()}
+      >
+        <SideDrawerSection>
           <div className='mb-4'>
             <h3 className='text-sm font-medium'>{t('Basic Information')}</h3>
             <p className='text-muted-foreground mt-1 text-xs'>
@@ -552,9 +753,9 @@ export function RolesSettings() {
               )}
             />
           </div>
-        </div>
+        </SideDrawerSection>
 
-        <div className='rounded-lg border bg-card p-4'>
+        <SideDrawerSection>
           <div className='mb-4'>
             <h3 className='text-sm font-medium'>{t('Permission Parameters')}</h3>
             <p className='text-muted-foreground mt-1 text-xs'>
@@ -562,39 +763,98 @@ export function RolesSettings() {
             </p>
           </div>
           <div className='space-y-3'>
-            {rolePermissionModules.map((module) => (
-              <div key={module.key} className='rounded-md border p-3'>
-                <div className='mb-3 flex items-start justify-between gap-3'>
-                  <div>
-                    <div className='text-sm font-medium'>{t(module.title)}</div>
-                    <div className='text-muted-foreground mt-1 text-xs'>
-                      {t(module.description)}
-                    </div>
+            <FormField
+              control={form.control}
+              name='permissions'
+              render={({ field }) => (
+                <FormItem>
+                  <div className='space-y-3'>
+                    {permissionModules.map((module) => {
+                      const selectedActions = (field.value[module.key] ?? []).filter(
+                        (permission) =>
+                          module.actions.some(
+                            (action) =>
+                              action.key === permission &&
+                              isPermissionActionSelectable(module, action)
+                          )
+                      )
+                      const viewPermissionKey = getViewPermissionKey(module)
+
+                      return (
+                        <div key={module.key} className='rounded-md border p-3'>
+                          <div className='mb-3 flex items-start justify-between gap-3'>
+                            <div>
+                              <div className='text-sm font-medium'>{t(module.title)}</div>
+                              {module.description ? (
+                                <div className='text-muted-foreground mt-1 text-xs'>
+                                  {t(module.description)}
+                                </div>
+                              ) : null}
+                            </div>
+                            <Switch
+                              checked={selectedActions.includes(viewPermissionKey)}
+                              onCheckedChange={(checked) => {
+                                field.onChange({
+                                  ...field.value,
+                                  [module.key]: checked
+                                    ? [...new Set([viewPermissionKey])]
+                                    : [],
+                                })
+                              }}
+                            />
+                          </div>
+                          {module.actions.length ? (
+                            <div className='grid grid-cols-2 gap-2 sm:grid-cols-4'>
+                              {module.actions.map((action) => {
+                                const isViewAction = action.key === viewPermissionKey
+                                const isSelectable = isPermissionActionSelectable(module, action)
+
+                                return (
+                                  <label
+                                    key={`${module.key}-${action.key}`}
+                                    className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${isSelectable ? 'hover:bg-accent cursor-pointer' : 'cursor-not-allowed opacity-50'}`}
+                                  >
+                                    <Checkbox
+                                      checked={isSelectable && selectedActions.includes(action.key)}
+                                      disabled={!isSelectable}
+                                      onCheckedChange={(checked) => {
+                                        if (!isSelectable) return
+                                        let nextActions = selectedActions.filter(
+                                          (key) => key !== action.key
+                                        )
+                                        if (checked) {
+                                          nextActions = [
+                                            ...new Set([
+                                              ...selectedActions,
+                                              viewPermissionKey,
+                                              action.key,
+                                            ]),
+                                          ]
+                                        } else if (isViewAction) {
+                                          nextActions = []
+                                        }
+                                        field.onChange({
+                                          ...field.value,
+                                          [module.key]: nextActions,
+                                        })
+                                      }}
+                                    />
+                                    <span>{t(action.label)}</span>
+                                  </label>
+                                )
+                              })}
+                            </div>
+                          ) : null}
+                        </div>
+                      )
+                    })}
                   </div>
-                  <Switch defaultChecked={module.key !== 'finance'} />
-                </div>
-                <div className='grid grid-cols-2 gap-2 sm:grid-cols-4'>
-                  {module.actions.map((action) => (
-                    <label
-                      key={`${module.key}-${action}`}
-                      className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm'
-                    >
-                      <Checkbox defaultChecked={action === 'View'} />
-                      <span>{t(action)}</span>
-                    </label>
-                  ))}
-                </div>
-              </div>
-            ))}
+                  <FormMessage />
+                </FormItem>
+              )}
+            />
           </div>
-        </div>
-
-        <div className='bg-background sticky bottom-0 flex justify-end gap-2 border-t py-4'>
-          <Button type='button' variant='outline' onClick={onCancel}>
-            {t('Cancel')}
-          </Button>
-          <Button type='submit'>{submitText}</Button>
-        </div>
+        </SideDrawerSection>
       </form>
     </Form>
   )
@@ -612,23 +872,31 @@ export function RolesSettings() {
               className='w-64'
             />
           </div>
-          <Sheet open={createOpen} onOpenChange={handleCreateOpenChange}>
-            <SheetTrigger render={<Button />}>
-              <Plus className='mr-2 h-4 w-4' />
-              {t('Create Role')}
-            </SheetTrigger>
-            <SheetContent className='w-full overflow-y-auto sm:max-w-2xl'>
-              <SheetHeader>
-                <SheetTitle>{t('Create Role')}</SheetTitle>
-                <SheetDescription>
-                  {t('Add a new platform role by providing necessary info.')}
-                </SheetDescription>
-              </SheetHeader>
-              <div className='mt-6'>
-                {renderRoleForm(() => setCreateOpen(false), t('Create'))}
-              </div>
-            </SheetContent>
-          </Sheet>
+          {canCreateRole && (
+            <Sheet open={createOpen} onOpenChange={handleCreateOpenChange}>
+              <SheetTrigger render={<Button />}>
+                <Plus className='mr-2 h-4 w-4' />
+                {t('Create Role')}
+              </SheetTrigger>
+              <SheetContent className={sideDrawerContentClassName('sm:max-w-2xl')}>
+                <SheetHeader className={sideDrawerHeaderClassName()}>
+                  <SheetTitle>{t('Create Role')}</SheetTitle>
+                  <SheetDescription>
+                    {t('Add a new platform role by providing necessary info.')}
+                  </SheetDescription>
+                </SheetHeader>
+                {renderRoleForm('role-create-form')}
+                <SheetFooter className={sideDrawerFooterClassName()}>
+                  <SheetClose render={<Button type='button' variant='outline' />}>
+                    {t('Cancel')}
+                  </SheetClose>
+                  <Button form='role-create-form' type='submit'>
+                    {t('Create')}
+                  </Button>
+                </SheetFooter>
+              </SheetContent>
+            </Sheet>
+          )}
         </div>
       </SectionPageLayout.Actions>
       <SectionPageLayout.Content>

+ 45 - 2
default/src/features/roles/types.ts

@@ -21,10 +21,45 @@ export interface Role {
   id: number
   name: string
   description: string
+  role_name?: string
+  role_desc?: string
+  tenant_id?: number
+  type?: string
+  is_can_edit?: number
   permission_modules_count: number
   permission_actions_count: number
-  created_at: number
-  updated_at: number
+  permissions?: Record<string, string[]>
+  permission_codes?: string[]
+  created_at: number | string
+  updated_at: number | string
+}
+
+export interface RawRoleRelationPermission {
+  permission_key?: string
+  permission_code?: string
+  resource?: string
+  action?: string
+  ext_permission?: {
+    permission_key?: string
+    permission_code?: string
+    resource?: string
+    action?: string
+  } | null
+}
+
+export interface RawRole {
+  id: number
+  role_name: string
+  role_desc: string
+  tenant_id: number
+  type: string
+  is_can_edit: number
+  permission_count?: number
+  permission_module_count?: number
+  permission_codes?: string[]
+  created_at: number | string
+  updated_at: number | string
+  role_relation_permissions?: RawRoleRelationPermission[] | null
 }
 
 export interface ApiResponse<T = unknown> {
@@ -37,6 +72,8 @@ export interface GetRolesResponse extends ApiResponse {
   data?: {
     items: Role[]
     total: number
+    page?: number
+    page_size?: number
   }
 }
 
@@ -46,6 +83,12 @@ export interface RoleFormData {
   permissions?: Record<string, string[]>
 }
 
+export interface CreateRolePayload {
+  role_name: string
+  role_desc?: string
+  permission_codes?: string[]
+}
+
 export interface SearchRolesParams {
   keyword?: string
   p?: number

+ 128 - 0
default/src/features/tenants/api.ts

@@ -0,0 +1,128 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { api } from '@/lib/api'
+
+import type {
+  CreateDirectChildTenantPayload,
+  CreateDirectChildTenantResponse,
+  GetDirectChildTenantDetailResponse,
+  GetDirectChildTenantsResponse,
+  RawDirectChildTenant,
+  Tenant,
+  TenantStatus,
+  UpdateDirectChildTenantPayload,
+  UpdateDirectChildTenantResponse,
+} from './types'
+
+function toTimestamp(value: number | string | null | undefined): number | null {
+  if (value == null || value === '') return null
+  if (typeof value === 'number') return value
+  const timestamp = Math.floor(new Date(value).getTime() / 1000)
+  return Number.isFinite(timestamp) ? timestamp : null
+}
+
+function normalizeTenantStatus(status: number | string): TenantStatus {
+  return Number(status) === 1 ? 'active' : 'disabled'
+}
+
+function normalizeDirectChildTenant(tenant: RawDirectChildTenant): Tenant {
+  const createdAt = toTimestamp(tenant.created_at)
+  const updatedAt = toTimestamp(tenant.updated_at)
+
+  return {
+    id: tenant.unique_id,
+    db_id: tenant.id,
+    parent_id: tenant.parent_id,
+    name: tenant.tenant_name,
+    code: tenant.unique_id,
+    group: tenant.group_name,
+    contact_phone: tenant.contact_phone ?? '',
+    admin_name: tenant.contact_person,
+    admin_email: tenant.contact_email ?? '',
+    admin_user_id: tenant.admin_user_id,
+    admin_registered: Boolean(tenant.has_admin_user),
+    status: normalizeTenantStatus(tenant.tenant_status),
+    quota_limit: tenant.quota_limit,
+    starts_at: toTimestamp(tenant.start_time),
+    ends_at: toTimestamp(tenant.end_time),
+    permission_keys: tenant.module_codes ?? [],
+    permission_codes: tenant.permission_codes,
+    root_role: tenant.root_role,
+    created_at: createdAt ?? 0,
+    updated_at: updatedAt ?? undefined,
+    remark: '',
+  }
+}
+
+function normalizeDirectChildTenantsResponse(response: GetDirectChildTenantsResponse) {
+  const items = Array.isArray(response.data)
+    ? response.data
+    : response.data?.items ?? []
+
+  return {
+    ...response,
+    data: items.map(normalizeDirectChildTenant),
+  }
+}
+
+export async function getDirectChildTenants() {
+  const res = await api.get<GetDirectChildTenantsResponse>(
+    '/api/tenants/direct-children/'
+  )
+  return normalizeDirectChildTenantsResponse(res.data)
+}
+
+export async function createDirectChildTenant(
+  payload: CreateDirectChildTenantPayload
+): Promise<Omit<CreateDirectChildTenantResponse, 'data'> & { data?: Tenant }> {
+  const res = await api.post<CreateDirectChildTenantResponse>(
+    '/api/tenants/direct-children/',
+    payload
+  )
+  return {
+    ...res.data,
+    data: res.data.data && 'tenant_name' in res.data.data
+      ? normalizeDirectChildTenant(res.data.data)
+      : res.data.data,
+  }
+}
+
+export async function updateDirectChildTenant(
+  uniqueId: string,
+  payload: UpdateDirectChildTenantPayload
+) {
+  const res = await api.put<UpdateDirectChildTenantResponse>(
+    `/api/tenants/direct-children/${uniqueId}`,
+    payload
+  )
+  return {
+    ...res.data,
+    data: res.data.data ? normalizeDirectChildTenant(res.data.data) : undefined,
+  }
+}
+
+export async function getDirectChildTenantDetail(uniqueId: string) {
+  const res = await api.get<GetDirectChildTenantDetailResponse>(
+    `/api/tenants/direct-children/${uniqueId}`
+  )
+  return {
+    ...res.data,
+    data: res.data.data ? normalizeDirectChildTenant(res.data.data) : undefined,
+  }
+}

+ 466 - 250
default/src/features/tenants/index.tsx

@@ -18,9 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
 */
 import type { ColumnDef } from '@tanstack/react-table'
 import { zodResolver } from '@hookform/resolvers/zod'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
 import { Building2, Eye, Info, Mail, Pencil, Plus, Power, PowerOff } from 'lucide-react'
-import { nanoid } from 'nanoid'
-import { useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
 import { useForm, type Resolver } from 'react-hook-form'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
@@ -72,8 +72,18 @@ import {
   TooltipTrigger,
 } from '@/components/ui/tooltip'
 import { copyToClipboard } from '@/lib/copy-to-clipboard'
-import { formatQuota, formatTimestamp } from '@/lib/format'
+import { formatNumber, formatTimestamp } from '@/lib/format'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
 
+import { getPermissionsWithRole, type PermissionItem } from '../permissions/api'
+import { getGroups } from '../users/api'
+import {
+  createDirectChildTenant,
+  getDirectChildTenantDetail,
+  getDirectChildTenants,
+  updateDirectChildTenant,
+} from './api'
 import type { Tenant, TenantFormData, TenantStatus } from './types'
 
 const TENANT_BASE_TIME = 1_725_523_200
@@ -82,12 +92,10 @@ const tenantStatusConfig: Record<
   TenantStatus,
   { labelKey: string; variant: StatusVariant }
 > = {
-  active: { labelKey: 'Normal', variant: 'success' },
+  active: { labelKey: 'Enabled', variant: 'success' },
   disabled: { labelKey: 'Disabled', variant: 'neutral' },
 }
 
-const tenantGroupOptions = ['医疗机构', '区域平台', '科研试用']
-
 const tenantPermissionModules = [
   {
     key: 'channels',
@@ -111,6 +119,63 @@ const tenantPermissionModules = [
   },
 ]
 
+type TenantPermissionModule = (typeof tenantPermissionModules)[number]
+
+function getTenantPermissionKey(permission: PermissionItem) {
+  return (
+    permission.module_code ??
+    permission.module_key ??
+    permission.module ??
+    permission.key ??
+    permission.permission_key ??
+    permission.code ??
+    permission.permission_code ??
+    permission.resource ??
+    String(permission.id ?? '')
+  )
+}
+
+function getTenantPermissionTitle(permission: PermissionItem, fallback: string) {
+  return (
+    permission.module_name ??
+    permission.module_label ??
+    permission.title ??
+    permission.name ??
+    permission.label ??
+    fallback
+  )
+}
+
+function permissionHasRole(permission: PermissionItem) {
+  return permission.has_permission ?? permission.checked ?? permission.selected ?? false
+}
+
+function toTenantPermissionModules(permissions: PermissionItem[]) {
+  if (!permissions.length) return tenantPermissionModules
+
+  return permissions.map((permission) => {
+    const key = getTenantPermissionKey(permission)
+    return {
+      key,
+      title: getTenantPermissionTitle(permission, key),
+      description: permission.module_desc ?? permission.description ?? '',
+    }
+  }).filter((module) => module.key) as TenantPermissionModule[]
+}
+
+function toCheckedPermissionKeys(permissions: PermissionItem[]) {
+  const keys: string[] = []
+  for (const permission of permissions) {
+    const key = getTenantPermissionKey(permission)
+    if (key && permissionHasRole(permission)) keys.push(key)
+    for (const child of permission.children ?? permission.permissions ?? []) {
+      const childKey = getTenantPermissionKey(child)
+      if (childKey && permissionHasRole(child)) keys.push(childKey)
+    }
+  }
+  return keys
+}
+
 const defaultTenantPermissionKeys = tenantPermissionModules.map((module) => module.key)
 
 const initialTenants: Tenant[] = [
@@ -167,34 +232,45 @@ const initialTenants: Tenant[] = [
   },
 ]
 
-function createTenantCode(name: string) {
-  const normalizedName = name.trim().toLowerCase()
-  if (!normalizedName) return `tenant-${Date.now()}`
-  return normalizedName.replaceAll(/\s+/g, '-').slice(0, 32)
-}
-
 function toDateInputValue(timestamp: number | null) {
   if (!timestamp) return ''
   return new Date(timestamp * 1000).toISOString().slice(0, 10)
 }
 
-function toTimestamp(value: string) {
-  if (!value) return null
-  return Math.floor(new Date(`${value}T23:59:59`).getTime() / 1000)
+function toApiDateTime(value: string, time: string) {
+  if (!value) return undefined
+  return `${value}T${time}+08:00`
+}
+
+function formatRootRole(rootRole: Tenant['root_role']) {
+  if (!rootRole) return ''
+  if (typeof rootRole === 'string') return rootRole
+  const roleName = rootRole['name'] ?? rootRole['role_name'] ?? rootRole['display_name']
+  return typeof roleName === 'string' ? roleName : JSON.stringify(rootRole)
 }
 
-function createMockPlatformId() {
-  return nanoid(32)
+function getInvitationLink(platformId: string, tenantName?: string) {
+  const invitationPath = `/token/${platformId}/register`
+  const searchParams = new URLSearchParams()
+  if (tenantName) searchParams.set('tenants', tenantName)
+  const queryString = searchParams.toString()
+  const invitationUrl = queryString ? `${invitationPath}?${queryString}` : invitationPath
+  if (typeof window === 'undefined') return invitationUrl
+  return `${window.location.origin}${invitationUrl}`
 }
 
-function getInvitationLink(platformId: string) {
-  const invitationPath = `/token/${platformId}/register?ent=ent_001`
-  if (typeof window === 'undefined') return invitationPath
-  return `${window.location.origin}${invitationPath}`
+function getInvitationLinkDisplay(platformId: string, tenantName?: string) {
+  return decodeURI(getInvitationLink(platformId, tenantName))
+}
+
+function getTenantPlatformId(tenant: Tenant) {
+  return tenant.code || String(tenant.id)
 }
 
 export function Tenants() {
   const { t } = useTranslation()
+  const queryClient = useQueryClient()
+  const currentUser = useAuthStore((state) => state.auth.user)
   const [searchValue, setSearchValue] = useState('')
   const [createOpen, setCreateOpen] = useState(false)
   const [editOpen, setEditOpen] = useState(false)
@@ -205,6 +281,76 @@ export function Tenants() {
   const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null)
   const [tenants, setTenants] = useState<Tenant[]>(initialTenants)
   const [submitAction, setSubmitAction] = useState<'save' | 'saveInvite'>('save')
+  const canCreateTenant = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.TENANT_CREATE
+  )
+  const canUpdateTenant = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.TENANT_UPDATE
+  )
+  const canDisableTenant = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.TENANT_DISABLE
+  )
+
+  const {
+    data: directChildTenants = initialTenants,
+    isLoading: isTenantsLoading,
+    isFetching: isTenantsFetching,
+  } = useQuery({
+    queryKey: ['tenants', 'direct-children'],
+    queryFn: async () => {
+      const result = await getDirectChildTenants()
+      if (!result.success) {
+        toast.error(result.message || t('Failed to load tenants'))
+        return initialTenants
+      }
+      return result.data
+    },
+  })
+
+  const { data: rolePermissions = [] } = useQuery({
+    queryKey: ['permissions', 'tenant-create-with-role'],
+    queryFn: () => getPermissionsWithRole(),
+  })
+
+  const { data: groupsData } = useQuery({
+    queryKey: ['groups'],
+    queryFn: getGroups,
+    staleTime: 5 * 60 * 1000,
+  })
+
+  const tenantDetailUniqueId = currentTenant?.code || String(currentTenant?.id ?? '')
+
+  const {
+    data: tenantDetail,
+    isLoading: isTenantDetailLoading,
+    isFetching: isTenantDetailFetching,
+  } = useQuery({
+    queryKey: ['tenants', 'direct-children', tenantDetailUniqueId],
+    queryFn: async () => {
+      const result = await getDirectChildTenantDetail(tenantDetailUniqueId)
+      if (!result.success) {
+        toast.error(result.message || t('Failed to load tenant details'))
+        return undefined
+      }
+      return result.data
+    },
+    enabled: (viewOpen || editOpen) && Boolean(tenantDetailUniqueId),
+  })
+
+  const tenantGroupOptions = groupsData?.data ?? []
+
+  const permissionModules = useMemo(
+    () => toTenantPermissionModules(rolePermissions),
+    [rolePermissions]
+  )
+
+  const currentTenantPermissionKeys = useMemo(() => {
+    const checkedKeys = toCheckedPermissionKeys(rolePermissions)
+    return checkedKeys.length ? checkedKeys : permissionModules.map((module) => module.key)
+  }, [permissionModules, rolePermissions])
 
   const tenantFormSchema = z.object({
     name: z.string().min(1, t('Tenant name is required')),
@@ -238,9 +384,10 @@ export function Tenants() {
   })
 
   const filteredTenants = useMemo(() => {
+    const tenantItems = directChildTenants.length ? directChildTenants : tenants
     const keyword = searchValue.trim().toLowerCase()
-    if (!keyword) return tenants
-    return tenants.filter((tenant) =>
+    if (!keyword) return tenantItems
+    return tenantItems.filter((tenant) =>
       [
         tenant.name,
         tenant.code,
@@ -250,9 +397,9 @@ export function Tenants() {
         tenant.contact_phone,
       ].some((field) => field.toLowerCase().includes(keyword))
     )
-  }, [searchValue, tenants])
+  }, [directChildTenants, searchValue, tenants])
 
-  const resetForm = (tenant?: Tenant | null) => {
+  const resetForm = useCallback((tenant?: Tenant | null) => {
     form.reset({
       name: tenant?.name ?? '',
       group: tenant?.group ?? '',
@@ -262,9 +409,13 @@ export function Tenants() {
       quota_limit: tenant?.quota_limit ?? 0,
       starts_at: toDateInputValue(tenant?.starts_at ?? null),
       ends_at: toDateInputValue(tenant?.ends_at ?? null),
-      permission_keys: tenant?.permission_keys ?? defaultTenantPermissionKeys,
+      permission_keys: tenant?.permission_keys ?? currentTenantPermissionKeys,
     })
-  }
+  }, [currentTenantPermissionKeys, form])
+
+  useEffect(() => {
+    if (editOpen && tenantDetail) resetForm(tenantDetail)
+  }, [editOpen, resetForm, tenantDetail])
 
   const handleCreateOpenChange = (open: boolean) => {
     setCreateOpen(open)
@@ -296,68 +447,85 @@ export function Tenants() {
     setInviteOpen(open)
     if (open) {
       setCurrentTenant(tenant)
-      setInvitePlatformId(createMockPlatformId())
+      setInvitePlatformId(getTenantPlatformId(tenant))
     }
   }
 
-  const handleSubmit = (data: TenantFormData) => {
-    const timestamp = Math.floor(Date.now() / 1000)
+  const openInvitationDialog = (tenant: Tenant) => {
+    setCurrentTenant(tenant)
+    setInvitePlatformId(getTenantPlatformId(tenant))
+    setInviteOpen(true)
+  }
+
+  const handleSubmit = async (data: TenantFormData) => {
     if (currentTenant) {
-      setTenants((prev) =>
-        prev.map((tenant) =>
-          tenant.id === currentTenant.id
-            ? {
-                ...tenant,
-                name: data.name,
-                group: data.group,
-                contact_phone: data.contact_phone,
-                admin_name: data.contact_name,
-                admin_email: data.contact_email,
-                quota_limit: data.quota_limit,
-                starts_at: toTimestamp(data.starts_at),
-                ends_at: toTimestamp(data.ends_at),
-                permission_keys: data.permission_keys,
-              }
-            : tenant
-        )
-      )
-      toast.success(t('Tenant updated successfully'))
-      if (submitAction === 'saveInvite' && data.contact_email) {
-        toast.success(t('Invitation sent to {{email}}', { email: data.contact_email }))
+      const uniqueId = currentTenant.code || String(currentTenant.id)
+
+      try {
+        const result = await updateDirectChildTenant(uniqueId, {
+          tenant_name: data.name,
+          contact_person: data.contact_name || undefined,
+          contact_phone: data.contact_phone || undefined,
+          contact_email: data.contact_email || undefined,
+          group_name: data.group,
+          quota_limit: data.quota_limit,
+          start_time: toApiDateTime(data.starts_at, '00:00:00'),
+          end_time: toApiDateTime(data.ends_at, '23:59:59'),
+          module_codes: data.permission_keys,
+        })
+
+        if (!result.success) {
+          toast.error(result.message || t('Failed to update tenant'))
+          return
+        }
+
+        toast.success(t('Tenant updated successfully'))
+        const savedTenant = result.data ?? currentTenant
+        setEditOpen(false)
+        await Promise.all([
+          queryClient.invalidateQueries({ queryKey: ['tenants', 'direct-children'] }),
+          queryClient.invalidateQueries({ queryKey: ['tenants', 'direct-children', uniqueId] }),
+        ])
+        if (submitAction === 'saveInvite') openInvitationDialog(savedTenant)
+      } catch {
+        toast.error(t('Failed to update tenant'))
       }
-      setEditOpen(false)
       return
     }
 
-    setTenants((prev) => [
-      {
-        id: Math.max(0, ...prev.map((tenant) => tenant.id)) + 1,
-        name: data.name,
-        code: createTenantCode(data.name),
-        group: data.group,
-        contact_phone: data.contact_phone,
-        admin_name: data.contact_name,
-        admin_email: data.contact_email,
-        admin_registered: false,
-        status: 'active',
+    try {
+      const result = await createDirectChildTenant({
+        tenant_name: data.name,
+        group_name: data.group,
+        contact_person: data.contact_name || undefined,
+        contact_phone: data.contact_phone || undefined,
+        contact_email: data.contact_email || undefined,
         quota_limit: data.quota_limit,
-        starts_at: toTimestamp(data.starts_at),
-        ends_at: toTimestamp(data.ends_at),
-        permission_keys: data.permission_keys,
-        created_at: timestamp,
-        remark: '',
-      },
-      ...prev,
-    ])
-    toast.success(t('Tenant created successfully'))
-    if (submitAction === 'saveInvite' && data.contact_email) {
-      toast.success(t('Invitation sent to {{email}}', { email: data.contact_email }))
+        start_time: toApiDateTime(data.starts_at, '00:00:00'),
+        end_time: toApiDateTime(data.ends_at, '23:59:59'),
+        module_codes: data.permission_keys,
+      })
+
+      if (!result.success) {
+        toast.error(result.message || t('Failed to create tenant'))
+        return
+      }
+
+      toast.success(t('Tenant created successfully'))
+      const savedTenant = result.data
+      setCreateOpen(false)
+      await queryClient.invalidateQueries({ queryKey: ['tenants', 'direct-children'] })
+      if (submitAction === 'saveInvite' && savedTenant) openInvitationDialog(savedTenant)
+    } catch {
+      toast.error(t('Failed to create tenant'))
     }
-    setCreateOpen(false)
   }
 
   const handleCopyInvitationLink = async () => {
-    const invitationLink = getInvitationLink(invitePlatformId || createMockPlatformId())
+    const invitationLink = getInvitationLink(
+      invitePlatformId || (currentTenant ? getTenantPlatformId(currentTenant) : ''),
+      currentTenant?.name
+    )
     const copied = await copyToClipboard(invitationLink)
 
     if (copied) {
@@ -368,11 +536,29 @@ export function Tenants() {
     toast.error(t('Failed to copy invitation link'))
   }
 
-  const handleToggleStatus = () => {
+  const handleToggleStatus = async () => {
     if (!currentTenant) return
 
     const nextStatus: TenantStatus =
       currentTenant.status === 'active' ? 'disabled' : 'active'
+    const nextTenantStatus = currentTenant.status === 'active' ? 2 : 1
+    const uniqueId = currentTenant.code || String(currentTenant.id)
+
+    try {
+      const result = await updateDirectChildTenant(uniqueId, {
+        tenant_status: nextTenantStatus,
+      })
+
+      if (!result.success) {
+        toast.error(result.message || t('Failed to update tenant'))
+        return
+      }
+
+      await queryClient.invalidateQueries({ queryKey: ['tenants', 'direct-children'] })
+    } catch {
+      toast.error(t('Failed to update tenant'))
+      return
+    }
 
     setTenants((prev) =>
       prev.map((item) =>
@@ -387,7 +573,11 @@ export function Tenants() {
     setToggleOpen(false)
   }
 
-  const renderTenantForm = (onCancel: () => void, submitText: string) => (
+  const renderTenantForm = (
+    onCancel: () => void,
+    submitText: string,
+    saveInviteDisabled = false
+  ) => (
     <Form {...form}>
       <form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
         <div className='rounded-lg border bg-card p-4'>
@@ -553,7 +743,7 @@ export function Tenants() {
             render={({ field }) => (
               <FormItem>
                 <div className='grid gap-3 sm:grid-cols-2'>
-                  {tenantPermissionModules.map((module) => (
+                  {permissionModules.map((module) => (
                     <label
                       key={module.key}
                       className='hover:bg-accent flex cursor-pointer items-start gap-3 rounded-md border p-3 text-sm'
@@ -589,7 +779,11 @@ export function Tenants() {
           <Button type='submit' onClick={() => setSubmitAction('save')}>
             {submitText}
           </Button>
-          <Button type='submit' onClick={() => setSubmitAction('saveInvite')}>
+          <Button
+            type='submit'
+            onClick={() => setSubmitAction('saveInvite')}
+            disabled={saveInviteDisabled}
+          >
             {t('Save and invite administrator')}
           </Button>
         </div>
@@ -655,7 +849,7 @@ export function Tenants() {
       header: t('Quota Limit'),
       cell: ({ row }) => (
         <span className='font-medium tabular-nums'>
-          {formatQuota(row.getValue('quota_limit') as number)}
+          ¥{formatNumber(row.getValue('quota_limit') as number)}
         </span>
       ),
       size: 160,
@@ -666,12 +860,11 @@ export function Tenants() {
       cell: ({ row }) => {
         const tenant = row.original
         return (
-          <div className='space-y-1 text-sm'>
-            <div>{tenant.starts_at ? formatTimestamp(tenant.starts_at) : t('Unlimited')}</div>
-            <div className='text-muted-foreground'>
-              {tenant.ends_at ? formatTimestamp(tenant.ends_at) : t('Unlimited')}
-            </div>
-          </div>
+          <span className='text-sm'>
+            {tenant.starts_at ? formatTimestamp(tenant.starts_at) : t('Unlimited')}
+            {' - '}
+            {tenant.ends_at ? formatTimestamp(tenant.ends_at) : t('Unlimited')}
+          </span>
         )
       },
       size: 220,
@@ -696,7 +889,11 @@ export function Tenants() {
       header: t('Actions'),
       cell: ({ row }) => {
         const tenant = row.original
+        const isViewingTenant = viewOpen && currentTenant?.id === tenant.id
+        const detailTenant = isViewingTenant && tenantDetail ? tenantDetail : tenant
+        const detailLoading = isViewingTenant && (isTenantDetailLoading || isTenantDetailFetching)
         const isActive = tenant.status === 'active'
+        const inviteDisabled = !isActive
         const toggleLabel = isActive ? t('Disable') : t('Enable')
         const toggleDescription = isActive
           ? t('Are you sure you want to disable tenant {{name}}?', {
@@ -708,7 +905,7 @@ export function Tenants() {
         return (
           <div className='flex items-center gap-1'>
             <Dialog
-              open={viewOpen && currentTenant?.id === tenant.id}
+              open={isViewingTenant}
               onOpenChange={(open) => handleViewOpenChange(open, tenant)}
             >
               <Tooltip>
@@ -735,6 +932,11 @@ export function Tenants() {
                   </DialogDescription>
                 </DialogHeader>
                 <div className='space-y-4'>
+                  {detailLoading && (
+                    <div className='text-muted-foreground rounded-lg border bg-card p-3 text-sm'>
+                      {t('Loading tenant details...')}
+                    </div>
+                  )}
                   <div className='flex items-center gap-3 rounded-lg border bg-card p-4'>
                     <div className='flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary'>
                       <Building2 className='size-5' />
@@ -742,55 +944,55 @@ export function Tenants() {
                     <div className='min-w-0 flex-1'>
                       <div className='flex flex-wrap items-center gap-2'>
                         <LongText className='max-w-[280px] font-medium'>
-                          {tenant.name}
+                          {detailTenant.name}
                         </LongText>
                         <StatusBadge
-                          label={t(tenantStatusConfig[tenant.status].labelKey)}
-                          variant={tenantStatusConfig[tenant.status].variant}
+                          label={t(tenantStatusConfig[detailTenant.status].labelKey)}
+                          variant={tenantStatusConfig[detailTenant.status].variant}
                           copyable={false}
                         />
                       </div>
                       <LongText className='text-muted-foreground mt-1 max-w-[360px] text-xs'>
-                        {tenant.code}
+                        {detailTenant.code}
                       </LongText>
                     </div>
                   </div>
 
                   <div className='grid overflow-hidden rounded-lg border text-sm sm:grid-cols-2'>
                     <div className='border-b p-4 sm:border-r'>
-                      <div className='text-muted-foreground'>{t('Tenant ID')}</div>
-                      <div className='mt-1 font-medium tabular-nums'>{tenant.id}</div>
+                      <div className='text-muted-foreground'>{t('Tenant Name')}</div>
+                      <div className='mt-1 font-medium'>{detailTenant.name}</div>
                     </div>
                     <div className='border-b p-4'>
-                      <div className='text-muted-foreground'>{t('Tenant Name')}</div>
-                      <div className='mt-1 font-medium'>{tenant.name}</div>
+                      <div className='text-muted-foreground'>{t('Tenant Code')}</div>
+                      <div className='mt-1 font-medium'>{detailTenant.code}</div>
                     </div>
                     <div className='border-b p-4 sm:border-r'>
-                      <div className='text-muted-foreground'>{t('Tenant Code')}</div>
-                      <div className='mt-1 font-medium'>{tenant.code}</div>
+                      <div className='text-muted-foreground'>{t('Group')}</div>
+                      <div className='mt-1 font-medium'>{detailTenant.group}</div>
                     </div>
                     <div className='border-b p-4'>
-                      <div className='text-muted-foreground'>{t('Group')}</div>
-                      <div className='mt-1 font-medium'>{tenant.group}</div>
+                      <div className='text-muted-foreground'>{t('Administrator')}</div>
+                      <div className='mt-1 font-medium'>{detailTenant.admin_name || t('N/A')}</div>
                     </div>
                     <div className='border-b p-4 sm:border-r'>
-                      <div className='text-muted-foreground'>{t('Administrator')}</div>
-                      <div className='mt-1 font-medium'>{tenant.admin_name}</div>
+                      <div className='text-muted-foreground'>{t('Contact Phone')}</div>
+                      <div className='mt-1 font-medium'>{detailTenant.contact_phone || t('N/A')}</div>
                     </div>
                     <div className='border-b p-4'>
                       <div className='text-muted-foreground'>{t('Administrator Email')}</div>
-                      <div className='mt-1 font-medium'>{tenant.admin_email}</div>
+                      <div className='mt-1 font-medium'>{detailTenant.admin_email || t('N/A')}</div>
                     </div>
                     <div className='border-b p-4 sm:border-r'>
                       <div className='text-muted-foreground'>{t('Administrator Status')}</div>
                       <div className='mt-2'>
                         <StatusBadge
                           label={
-                            tenant.admin_registered
+                            detailTenant.admin_registered
                               ? t('Registered')
                               : t('Unregistered')
                           }
-                          variant={tenant.admin_registered ? 'success' : 'warning'}
+                          variant={detailTenant.admin_registered ? 'success' : 'warning'}
                           copyable={false}
                         />
                       </div>
@@ -799,8 +1001,8 @@ export function Tenants() {
                       <div className='text-muted-foreground'>{t('Status')}</div>
                       <div className='mt-2'>
                         <StatusBadge
-                          label={t(tenantStatusConfig[tenant.status].labelKey)}
-                          variant={tenantStatusConfig[tenant.status].variant}
+                          label={t(tenantStatusConfig[detailTenant.status].labelKey)}
+                          variant={tenantStatusConfig[detailTenant.status].variant}
                           copyable={false}
                         />
                       </div>
@@ -808,31 +1010,37 @@ export function Tenants() {
                     <div className='border-b p-4 sm:border-r'>
                       <div className='text-muted-foreground'>{t('Quota Limit')}</div>
                       <div className='mt-1 font-medium tabular-nums'>
-                        {formatQuota(tenant.quota_limit)}
+                        ¥{formatNumber(detailTenant.quota_limit)}
                       </div>
                     </div>
                     <div className='border-b p-4'>
                       <div className='text-muted-foreground'>{t('Validity Period')}</div>
                       <div className='mt-1 font-medium'>
-                        {tenant.starts_at
-                          ? formatTimestamp(tenant.starts_at)
+                        {detailTenant.starts_at
+                          ? formatTimestamp(detailTenant.starts_at)
                           : t('Unlimited')}
                         {' - '}
-                        {tenant.ends_at
-                          ? formatTimestamp(tenant.ends_at)
+                        {detailTenant.ends_at
+                          ? formatTimestamp(detailTenant.ends_at)
                           : t('Unlimited')}
                       </div>
                     </div>
-                    <div className='border-b p-4 sm:border-r sm:border-b-0'>
+                    <div className='border-b p-4 sm:border-r'>
                       <div className='text-muted-foreground'>{t('Created At')}</div>
                       <div className='mt-1 font-medium'>
-                        {formatTimestamp(tenant.created_at)}
+                        {formatTimestamp(detailTenant.created_at)}
                       </div>
                     </div>
-                    <div className='p-4'>
+                    <div className='border-b p-4'>
+                      <div className='text-muted-foreground'>{t('Root Role')}</div>
+                      <div className='mt-1 font-medium'>
+                        {formatRootRole(detailTenant.root_role) || t('N/A')}
+                      </div>
+                    </div>
+                    <div className='p-4 sm:col-span-2'>
                       <div className='text-muted-foreground'>{t('Remark')}</div>
                       <div className='mt-1 font-medium'>
-                        {tenant.remark || t('No remark')}
+                        {detailTenant.remark || t('No remark')}
                       </div>
                     </div>
                   </div>
@@ -847,8 +1055,8 @@ export function Tenants() {
                       </p>
                     </div>
                     <div className='grid gap-3 sm:grid-cols-2'>
-                      {tenantPermissionModules.map((module) => {
-                        const moduleEnabled = tenant.permission_keys.includes(module.key)
+                      {permissionModules.map((module) => {
+                        const moduleEnabled = detailTenant.permission_keys.includes(module.key)
 
                         return (
                           <div key={module.key} className='rounded-md border p-3'>
@@ -875,42 +1083,41 @@ export function Tenants() {
                 </div>
               </DialogContent>
             </Dialog>
-            <Sheet
-              open={editOpen && currentTenant?.id === tenant.id}
-              onOpenChange={(open) => handleEditOpenChange(open, tenant)}
-            >
-              <Tooltip>
-                <TooltipTrigger
-                  render={
-                    <Button
-                      type='button'
-                      variant='ghost'
-                      size='icon-sm'
-                      onClick={() => handleEditOpenChange(true, tenant)}
-                      aria-label={t('Edit')}
-                    />
-                  }
-                >
-                  <Pencil />
-                </TooltipTrigger>
-                <TooltipContent>{t('Edit')}</TooltipContent>
-              </Tooltip>
-              <SheetContent className='w-full overflow-y-auto sm:max-w-2xl'>
-                <SheetHeader>
-                  <SheetTitle>{t('Edit Tenant')}</SheetTitle>
-                  <SheetDescription>
-                    {t('Update tenant information and resource settings.')}
-                  </SheetDescription>
-                </SheetHeader>
-                <div className='mt-6'>
-                  {renderTenantForm(() => setEditOpen(false), t('Save changes'))}
-                </div>
-              </SheetContent>
-            </Sheet>
-            <Dialog
-              open={inviteOpen && currentTenant?.id === tenant.id}
-              onOpenChange={(open) => handleInviteOpenChange(open, tenant)}
-            >
+            {canUpdateTenant && (
+              <Sheet
+                open={editOpen && currentTenant?.id === tenant.id}
+                onOpenChange={(open) => handleEditOpenChange(open, tenant)}
+              >
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <Button
+                        type='button'
+                        variant='ghost'
+                        size='icon-sm'
+                        onClick={() => handleEditOpenChange(true, tenant)}
+                        aria-label={t('Edit')}
+                      />
+                    }
+                  >
+                    <Pencil />
+                  </TooltipTrigger>
+                  <TooltipContent>{t('Edit')}</TooltipContent>
+                </Tooltip>
+                <SheetContent className='w-full overflow-y-auto sm:max-w-2xl'>
+                  <SheetHeader>
+                    <SheetTitle>{t('Edit Tenant')}</SheetTitle>
+                    <SheetDescription>
+                      {t('Update tenant information and resource settings.')}
+                    </SheetDescription>
+                  </SheetHeader>
+                  <div className='mt-6'>
+                    {renderTenantForm(() => setEditOpen(false), t('Save changes'), !isActive)}
+                  </div>
+                </SheetContent>
+              </Sheet>
+            )}
+            {canCreateTenant && (
               <Tooltip>
                 <TooltipTrigger
                   render={
@@ -919,6 +1126,7 @@ export function Tenants() {
                       variant='ghost'
                       size='icon-sm'
                       onClick={() => handleInviteOpenChange(true, tenant)}
+                      disabled={inviteDisabled}
                       aria-label={t('Invite administrator to register')}
                     />
                   }
@@ -927,86 +1135,52 @@ export function Tenants() {
                 </TooltipTrigger>
                 <TooltipContent>{t('Invite administrator to register')}</TooltipContent>
               </Tooltip>
-              <DialogContent className='sm:max-w-md'>
-                <DialogHeader>
-                  <DialogTitle>{t('Administrator Registration')}</DialogTitle>
-                </DialogHeader>
-                <div className='space-y-4'>
-                  <div className='bg-muted/50 flex gap-3 rounded-lg border p-3'>
-                    <div className='bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-full'>
-                      <Info className='size-4' />
-                    </div>
-                    <div className='space-y-2'>
-                      <p className='text-foreground text-base font-medium'>
-                        {t('Send the link below to the administrator of {{tenantName}}', {
-                          tenantName: tenant.name,
-                        })}
-                      </p>
-                      <p className='text-muted-foreground text-sm leading-6'>
-                        {t(
-                          'After opening the link, the administrator enters the welcome registration page and only needs to fill in the account and password to complete registration and enter the institution portal. They will automatically see the menu and button permissions you assigned.'
-                        )}
-                      </p>
-                    </div>
-                  </div>
-                  <Input
-                    value={getInvitationLink(invitePlatformId || createMockPlatformId())}
-                    readOnly
-                  />
-                </div>
-                <DialogFooter>
-                  <Button variant='outline' onClick={() => setInviteOpen(false)}>
-                    {t('Close')}
-                  </Button>
-                  <Button onClick={handleCopyInvitationLink}>
-                    {t('Copy invitation link')}
-                  </Button>
-                </DialogFooter>
-              </DialogContent>
-            </Dialog>
-            <Dialog
-              open={toggleOpen && currentTenant?.id === tenant.id}
-              onOpenChange={(open) => handleToggleOpenChange(open, tenant)}
-            >
-              <Tooltip>
-                <TooltipTrigger
-                  render={
-                    <Button
-                      type='button'
-                      variant='ghost'
-                      size='icon-sm'
-                      onClick={() => handleToggleOpenChange(true, tenant)}
-                      aria-label={toggleLabel}
-                      className={
-                        isActive
-                          ? 'text-destructive hover:text-destructive'
-                          : 'text-success hover:text-success'
-                      }
-                    />
-                  }
-                >
-                  {isActive ? <PowerOff /> : <Power />}
-                </TooltipTrigger>
-                <TooltipContent>{toggleLabel}</TooltipContent>
-              </Tooltip>
-              <DialogContent>
-                <DialogHeader>
-                  <DialogTitle>{toggleLabel}</DialogTitle>
-                  <DialogDescription>{toggleDescription}</DialogDescription>
-                </DialogHeader>
-                <DialogFooter>
-                  <Button variant='outline' onClick={() => setToggleOpen(false)}>
-                    {t('Cancel')}
-                  </Button>
-                  <Button
-                    variant={isActive ? 'destructive' : 'default'}
-                    onClick={handleToggleStatus}
+            )}
+            {canDisableTenant && (
+              <Dialog
+                open={toggleOpen && currentTenant?.id === tenant.id}
+                onOpenChange={(open) => handleToggleOpenChange(open, tenant)}
+              >
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <Button
+                        type='button'
+                        variant='ghost'
+                        size='icon-sm'
+                        onClick={() => handleToggleOpenChange(true, tenant)}
+                        aria-label={toggleLabel}
+                        className={
+                          isActive
+                            ? 'text-destructive hover:text-destructive'
+                            : 'text-success hover:text-success'
+                        }
+                      />
+                    }
                   >
-                    {toggleLabel}
-                  </Button>
-                </DialogFooter>
-              </DialogContent>
-            </Dialog>
+                    {isActive ? <PowerOff /> : <Power />}
+                  </TooltipTrigger>
+                  <TooltipContent>{toggleLabel}</TooltipContent>
+                </Tooltip>
+                <DialogContent>
+                  <DialogHeader>
+                    <DialogTitle>{toggleLabel}</DialogTitle>
+                    <DialogDescription>{toggleDescription}</DialogDescription>
+                  </DialogHeader>
+                  <DialogFooter>
+                    <Button variant='outline' onClick={() => setToggleOpen(false)}>
+                      {t('Cancel')}
+                    </Button>
+                    <Button
+                      variant={isActive ? 'destructive' : 'default'}
+                      onClick={handleToggleStatus}
+                    >
+                      {toggleLabel}
+                    </Button>
+                  </DialogFooter>
+                </DialogContent>
+              </Dialog>
+            )}
           </div>
         )
       },
@@ -1032,34 +1206,76 @@ export function Tenants() {
             onChange={(event) => setSearchValue(event.target.value)}
             className='w-72'
           />
-          <Sheet open={createOpen} onOpenChange={handleCreateOpenChange}>
-            <SheetTrigger render={<Button />}>
-              <Plus className='mr-2 h-4 w-4' />
-              {t('Create Tenant')}
-            </SheetTrigger>
-            <SheetContent className='w-full overflow-y-auto sm:max-w-2xl'>
-              <SheetHeader>
-                <SheetTitle>{t('Create Tenant')}</SheetTitle>
-                <SheetDescription>
-                  {t('Create a new tenant and assign initial resource settings.')}
-                </SheetDescription>
-              </SheetHeader>
-              <div className='mt-6'>
-                {renderTenantForm(() => setCreateOpen(false), t('Create'))}
-              </div>
-            </SheetContent>
-          </Sheet>
+          {canCreateTenant && (
+            <Sheet open={createOpen} onOpenChange={handleCreateOpenChange}>
+              <SheetTrigger render={<Button />}>
+                <Plus className='mr-2 h-4 w-4' />
+                {t('Create Tenant')}
+              </SheetTrigger>
+              <SheetContent className='w-full overflow-y-auto sm:max-w-2xl'>
+                <SheetHeader>
+                  <SheetTitle>{t('Create Tenant')}</SheetTitle>
+                  <SheetDescription>
+                    {t('Create a new tenant and assign initial resource settings.')}
+                  </SheetDescription>
+                </SheetHeader>
+                <div className='mt-6'>
+                  {renderTenantForm(() => setCreateOpen(false), t('Create'))}
+                </div>
+              </SheetContent>
+            </Sheet>
+          )}
         </div>
       </SectionPageLayout.Actions>
       <SectionPageLayout.Content>
         <DataTablePage
           table={table}
           columns={columns}
+          isLoading={isTenantsLoading}
+          isFetching={isTenantsFetching}
           emptyTitle={t('No Tenants Found')}
           emptyDescription={t('No tenants available. Try adjusting your search.')}
           skeletonKeyPrefix='tenants-skeleton'
           applyHeaderSize
         />
+        <Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
+          <DialogContent className='sm:max-w-md'>
+            <DialogHeader>
+              <DialogTitle>{t('Administrator Registration')}</DialogTitle>
+            </DialogHeader>
+            <div className='space-y-4'>
+              <div className='bg-muted/50 flex gap-3 rounded-lg border p-3'>
+                <div className='bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-full'>
+                  <Info className='size-4' />
+                </div>
+                <div className='space-y-2'>
+                  <p className='text-foreground text-base font-medium'>
+                    {t('Send the link below to the administrator of {{tenantName}}', {
+                      tenantName: currentTenant?.name ?? '',
+                    })}
+                  </p>
+                  <p className='text-muted-foreground text-sm leading-6'>
+                    {t(
+                      'After opening the link, the administrator enters the welcome registration page and only needs to fill in the account and password to complete registration and enter the institution portal. They will automatically see the menu and button permissions you assigned.'
+                    )}
+                  </p>
+                </div>
+              </div>
+              <Input
+                value={getInvitationLinkDisplay(invitePlatformId, currentTenant?.name)}
+                readOnly
+              />
+            </div>
+            <DialogFooter>
+              <Button variant='outline' onClick={() => setInviteOpen(false)}>
+                {t('Close')}
+              </Button>
+              <Button onClick={handleCopyInvitationLink}>
+                {t('Copy invitation link')}
+              </Button>
+            </DialogFooter>
+          </DialogContent>
+        </Dialog>
       </SectionPageLayout.Content>
     </SectionPageLayout>
   )

+ 71 - 1
default/src/features/tenants/types.ts

@@ -20,23 +20,93 @@ For commercial licensing, please contact support@quantumnous.com
 export type TenantStatus = 'active' | 'disabled'
 
 export interface Tenant {
-  id: number
+  id: number | string
+  db_id?: number | string
+  parent_id?: number | string
   name: string
   code: string
   group: string
   contact_phone: string
   admin_name: string
   admin_email: string
+  admin_user_id?: number | string
   admin_registered: boolean
   status: TenantStatus
   quota_limit: number
   starts_at: number | null
   ends_at: number | null
   permission_keys: string[]
+  permission_codes?: string[]
+  root_role?: string | Record<string, unknown> | null
   created_at: number
+  updated_at?: number
   remark: string
 }
 
+export interface RawDirectChildTenant {
+  id?: number | string
+  parent_id?: number | string
+  tenant_name: string
+  tenant_status: number | string
+  group_name: string
+  contact_person: string
+  contact_phone?: string
+  contact_email?: string
+  admin_user_id?: number | string
+  has_admin_user?: boolean
+  quota_limit: number
+  start_time: number | string | null
+  end_time: number | string | null
+  unique_id: string
+  module_codes?: string[]
+  root_role?: string | Record<string, unknown> | null
+  permission_codes?: string[]
+  created_at: number | string
+  updated_at: number | string
+}
+
+export interface GetDirectChildTenantsResponse {
+  success: boolean
+  message?: string
+  data?: RawDirectChildTenant[] | {
+    items?: RawDirectChildTenant[]
+  }
+}
+
+export interface CreateDirectChildTenantPayload {
+  tenant_name: string
+  group_name: string
+  contact_person?: string
+  contact_phone?: string
+  contact_email?: string
+  quota_limit?: number
+  start_time?: string
+  end_time?: string
+  module_codes?: string[]
+}
+
+export interface UpdateDirectChildTenantPayload extends Partial<CreateDirectChildTenantPayload> {
+  tenant_status?: number
+}
+
+export interface CreateDirectChildTenantResponse {
+  success: boolean
+  message?: string
+  data?: RawDirectChildTenant | Tenant
+}
+
+export interface UpdateDirectChildTenantResponse {
+  success: boolean
+  message?: string
+  data?: RawDirectChildTenant
+}
+
+export interface GetDirectChildTenantDetailResponse {
+  success: boolean
+  message?: string
+  data?: RawDirectChildTenant
+}
+
 export interface TenantFormData {
   name: string
   group: string

+ 27 - 19
default/src/features/users/components/data-table-row-actions.tsx

@@ -47,6 +47,8 @@ import {
   TooltipTrigger,
 } from '@/components/ui/tooltip'
 import { UserSubscriptionsDialog } from '@/features/subscriptions/components/dialogs/user-subscriptions-dialog'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
 
 import { manageUser, resetUserPasskey, resetUserTwoFA } from '../api'
 import {
@@ -72,6 +74,10 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
   const [resetTwoFAOpen, setResetTwoFAOpen] = useState(false)
   const [bindingDialogOpen, setBindingDialogOpen] = useState(false)
   const [subscriptionsDialogOpen, setSubscriptionsDialogOpen] = useState(false)
+  const currentUser = useAuthStore((s) => s.auth.user)
+  const canUpdateUser = hasPermissionCode(currentUser, PERMISSION_CODES.USER_UPDATE)
+  const canDeleteUser = hasPermissionCode(currentUser, PERMISSION_CODES.USER_DELETE)
+  const canDisableUser = hasPermissionCode(currentUser, PERMISSION_CODES.USER_DISABLE)
 
   const handleEdit = () => {
     setCurrentRow(user)
@@ -141,34 +147,36 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
 
   return (
     <div className='-ml-1.5 flex items-center gap-1'>
-      <Tooltip>
-        <TooltipTrigger
-          render={
-            <Button
-              variant='ghost'
-              size='icon-sm'
-              onClick={handleEdit}
-              aria-label={t('Edit')}
-            />
-          }
-        >
-          <Pencil />
-        </TooltipTrigger>
-        <TooltipContent>{t('Edit')}</TooltipContent>
-      </Tooltip>
+      {canUpdateUser && (
+        <Tooltip>
+          <TooltipTrigger
+            render={
+              <Button
+                variant='ghost'
+                size='icon-sm'
+                onClick={handleEdit}
+                aria-label={t('Edit')}
+              />
+            }
+          >
+            <Pencil />
+          </TooltipTrigger>
+          <TooltipContent>{t('Edit')}</TooltipContent>
+        </Tooltip>
+      )}
 
       <DataTableRowActionMenu
         ariaLabel={t('Open menu')}
         contentClassName='w-48'
       >
-        {isDisabled ? (
+        {canDisableUser && isDisabled ? (
           <DropdownMenuItem onClick={() => handleManage('enable')}>
             {t('Enable')}
             <DropdownMenuShortcut>
               <Power size={16} />
             </DropdownMenuShortcut>
           </DropdownMenuItem>
-        ) : (
+        ) : canDisableUser ? (
           <DropdownMenuItem
             onClick={() => handleManage('disable')}
             disabled={isRoot}
@@ -178,7 +186,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
               <PowerOff size={16} />
             </DropdownMenuShortcut>
           </DropdownMenuItem>
-        )}
+        ) : null}
 
         {isAdmin && !isRoot && (
           <DropdownMenuItem onClick={() => handleManage('demote')}>
@@ -255,7 +263,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
         <DropdownMenuItem
           onClick={handleDelete}
           className='text-destructive focus:text-destructive'
-          disabled={isRoot}
+          disabled={isRoot || !canDeleteUser}
         >
           {t('Delete')}
           <DropdownMenuShortcut>

+ 8 - 3
default/src/features/users/components/users-columns.tsx

@@ -252,19 +252,24 @@ export function useUsersColumns(): ColumnDef<User>[] {
       accessorKey: 'role',
       header: t('Role'),
       cell: ({ row }) => {
+        const relationRoleName = row.original.ext_user_relation_role?.find(
+          (relation) => relation.ext_role?.role_name
+        )?.ext_role?.role_name
         const roleValue = row.getValue('role') as number
         const roleConfig = USER_ROLES[roleValue as keyof typeof USER_ROLES]
 
-        if (!roleConfig) {
+        if (!roleConfig && !relationRoleName) {
           return null
         }
 
         return (
           <div className='flex items-center gap-x-2'>
-            {roleConfig.icon && (
+            {roleConfig?.icon && (
               <roleConfig.icon size={16} className='text-muted-foreground' />
             )}
-            <span className='text-sm'>{t(roleConfig.labelKey)}</span>
+            <span className='text-sm'>
+              {relationRoleName || t(roleConfig.labelKey)}
+            </span>
           </div>
         )
       },

+ 61 - 144
default/src/features/users/components/users-mutate-drawer.tsx

@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
 import { zodResolver } from '@hookform/resolvers/zod'
 import { useQuery } from '@tanstack/react-query'
 import { Pencil } from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
 import { useForm } from 'react-hook-form'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
@@ -32,7 +32,6 @@ import {
   sideDrawerHeaderClassName,
 } from '@/components/drawer-layout'
 import { Button } from '@/components/ui/button'
-import { Checkbox } from '@/components/ui/checkbox'
 import {
   Form,
   FormControl,
@@ -61,17 +60,10 @@ import {
   SheetTitle,
 } from '@/components/ui/sheet'
 import { Textarea } from '@/components/ui/textarea'
-import {
-  ADMIN_PERMISSION_ACTIONS,
-  ADMIN_PERMISSION_RESOURCES,
-  EMPTY_PERMISSION_CATALOG,
-  hasPermission,
-  normalizeAdminPermissions,
-} from '@/lib/admin-permissions'
+import { getRoles } from '@/features/roles/api'
+import { EMPTY_PERMISSION_CATALOG } from '@/lib/admin-permissions'
 import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
 import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
-import { ROLE } from '@/lib/roles'
-import { useAuthStore } from '@/stores/auth-store'
 
 import {
   createUser,
@@ -83,7 +75,6 @@ import {
 import {
   ERROR_MESSAGES,
   SUCCESS_MESSAGES,
-  getUserRoleOptions,
 } from '../constants'
 import {
   userFormSchema,
@@ -92,7 +83,7 @@ import {
   transformFormDataToPayload,
   transformUserToFormDefaults,
 } from '../lib'
-import { type User } from '../types'
+import type { User } from '../types'
 import { UserQuotaDialog } from './user-quota-dialog'
 import { useUsers } from './users-provider'
 
@@ -110,7 +101,6 @@ export function UsersMutateDrawer({
   const { t } = useTranslation()
   const isUpdate = !!currentRow
   const { triggerRefresh } = useUsers()
-  const currentUser = useAuthStore((s) => s.auth.user)
   const [isSubmitting, setIsSubmitting] = useState(false)
   const [quotaDialogOpen, setQuotaDialogOpen] = useState(false)
 
@@ -123,6 +113,20 @@ export function UsersMutateDrawer({
 
   const groups = groupsData?.data || []
 
+  const { data: rolesData } = useQuery({
+    queryKey: ['roles', 'user-form'],
+    queryFn: () => getRoles({ p: 1, page_size: 100 }),
+    staleTime: 5 * 60 * 1000,
+  })
+
+  const roleOptions = useMemo(
+    () => rolesData?.data?.items.map((role) => ({
+      value: String(role.id),
+      label: role.name || role.role_name || String(role.id),
+    })) || [],
+    [rolesData]
+  )
+
   // Permission catalog is owned by the backend; fetched once and reused.
   const { data: permissionCatalog = EMPTY_PERMISSION_CATALOG } = useQuery({
     queryKey: ['admin-permission-catalog'],
@@ -139,7 +143,7 @@ export function UsersMutateDrawer({
   useEffect(() => {
     if (open && isUpdate && currentRow) {
       // For update, fetch fresh data
-      getUser(currentRow.id).then((result) => {
+      void getUser(currentRow.id).then((result) => {
         if (result.success && result.data) {
           form.reset(transformUserToFormDefaults(result.data))
         }
@@ -150,14 +154,22 @@ export function UsersMutateDrawer({
     }
   }, [open, isUpdate, currentRow, form])
 
+  useEffect(() => {
+    if (!open || isUpdate || roleOptions.length === 0) return
+    const currentRole = form.getValues('role')
+    const hasCurrentRole = roleOptions.some(
+      (option) => Number.parseInt(option.value) === currentRole
+    )
+    if (!hasCurrentRole) {
+      form.setValue('role', Number.parseInt(roleOptions[0].value))
+    }
+  }, [open, isUpdate, roleOptions, form])
+
   const { meta: currencyMeta } = getCurrencyDisplay()
   const currencyLabel = getCurrencyLabel()
   const tokensOnly = currencyMeta.kind === 'tokens'
 
   const currentQuotaRaw = form.watch('quota_dollars') || 0
-  const selectedRole = form.watch('role')
-  const canEditAdminPermissions = currentUser?.role === ROLE.SUPER_ADMIN
-  const targetIsAdmin = (selectedRole ?? currentRow?.role ?? 0) >= ROLE.ADMIN
 
   const onSubmit = async (data: UserFormValues) => {
     if (!isUpdate) {
@@ -198,7 +210,7 @@ export function UsersMutateDrawer({
               : t(ERROR_MESSAGES.CREATE_FAILED))
         )
       }
-    } catch (_error) {
+    } catch {
       toast.error(t(ERROR_MESSAGES.UNEXPECTED))
     } finally {
       setIsSubmitting(false)
@@ -271,37 +283,34 @@ export function UsersMutateDrawer({
                 <FormField
                   control={form.control}
                   name='role'
-                  render={({ field }) => {
-                    const roleOptions = getUserRoleOptions(t)
-                    return (
-                      <FormItem>
-                        <FormLabel>{t('Role')}</FormLabel>
-                        <Select
-                          items={roleOptions}
-                          onValueChange={(value) =>
-                            value !== null && field.onChange(parseInt(value))
-                          }
-                          value={String(field.value)}
-                        >
-                          <FormControl>
-                            <SelectTrigger>
-                              <SelectValue placeholder={t('Select a role')} />
-                            </SelectTrigger>
-                          </FormControl>
-                          <SelectContent alignItemWithTrigger={false}>
-                            <SelectGroup>
-                              {roleOptions.map((option) => (
-                                <SelectItem key={option.value} value={option.value}>
-                                  {option.label}
-                                </SelectItem>
-                              ))}
-                            </SelectGroup>
-                          </SelectContent>
-                        </Select>
-                        <FormMessage />
-                      </FormItem>
-                    )
-                  }}
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>{t('Role')}</FormLabel>
+                      <Select
+                        items={roleOptions}
+                        onValueChange={(value) =>
+                          value !== null && field.onChange(Number.parseInt(value))
+                        }
+                        value={String(field.value)}
+                      >
+                        <FormControl>
+                          <SelectTrigger>
+                            <SelectValue placeholder={t('Select a role')} />
+                          </SelectTrigger>
+                        </FormControl>
+                        <SelectContent alignItemWithTrigger={false}>
+                          <SelectGroup>
+                            {roleOptions.map((option) => (
+                              <SelectItem key={option.value} value={option.value}>
+                                {option.label}
+                              </SelectItem>
+                            ))}
+                          </SelectGroup>
+                        </SelectContent>
+                      </Select>
+                      <FormMessage />
+                    </FormItem>
+                  )}
                 />
 
                 <FormField
@@ -359,12 +368,10 @@ export function UsersMutateDrawer({
                       <FormItem>
                         <FormLabel>{t('Group')}</FormLabel>
                         <Select
-                          items={[
-                            ...groups.map((group) => ({
+                          items={groups.map((group) => ({
                               value: group,
                               label: group,
-                            })),
-                          ]}
+                            }))}
                           onValueChange={field.onChange}
                           value={field.value}
                         >
@@ -449,96 +456,6 @@ export function UsersMutateDrawer({
                 </SideDrawerSection>
               )}
 
-              {canEditAdminPermissions &&
-                targetIsAdmin &&
-                permissionCatalog.resources.length > 0 && (
-                  <SideDrawerSection>
-                    <h3 className='text-sm font-medium'>
-                      {t('Admin Permissions')}
-                    </h3>
-                    <p className='text-muted-foreground text-xs'>
-                      {t(
-                        'Default administrator permissions can be overridden for this user.'
-                      )}
-                    </p>
-                    <FormField
-                      control={form.control}
-                      name='admin_permissions'
-                      render={({ field }) => {
-                        const selected = normalizeAdminPermissions(
-                          field.value,
-                          permissionCatalog
-                        )
-                        return (
-                          <FormItem>
-                            <div className='space-y-3'>
-                              {permissionCatalog.resources.map((resource) => (
-                                <div
-                                  key={resource.resource}
-                                  className='space-y-2 rounded-md border p-3'
-                                >
-                                  <div className='text-sm font-medium'>
-                                    {t(resource.label_key)}
-                                  </div>
-                                  <div className='space-y-2'>
-                                    {resource.actions.map((option) => (
-                                      <label
-                                        key={option.action}
-                                        className='flex items-start gap-3'
-                                      >
-                                        <Checkbox
-                                          checked={
-                                            selected[resource.resource]?.[
-                                              option.action
-                                            ] === true
-                                          }
-                                          onCheckedChange={(checked) => {
-                                            field.onChange({
-                                              ...selected,
-                                              [resource.resource]: {
-                                                ...selected[resource.resource],
-                                                [option.action]:
-                                                  checked === true,
-                                              },
-                                            })
-                                          }}
-                                        />
-                                        <span className='flex flex-col gap-1'>
-                                          <span className='text-sm font-medium'>
-                                            {t(option.label_key)}
-                                          </span>
-                                          <span className='text-muted-foreground text-xs'>
-                                            {t(option.description_key)}
-                                          </span>
-                                        </span>
-                                      </label>
-                                    ))}
-                                  </div>
-                                </div>
-                              ))}
-                            </div>
-                            <FormMessage />
-                          </FormItem>
-                        )
-                      }}
-                    />
-                    {currentUser && (
-                      <p className='text-muted-foreground text-xs'>
-                        {hasPermission(
-                          currentUser,
-                          ADMIN_PERMISSION_RESOURCES.CHANNEL,
-                          ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
-                        )
-                          ? t(
-                              'Your account can edit sensitive channel settings.'
-                            )
-                          : t(
-                              'Your account cannot edit sensitive channel settings.'
-                            )}
-                      </p>
-                    )}
-                  </SideDrawerSection>
-                )}
               {/* 绑定信息  */}
               {/* Binding Information (Read-only) - commented out */}
               {/* {isUpdate && (

+ 10 - 4
default/src/features/users/components/users-primary-buttons.tsx

@@ -20,12 +20,16 @@ import { Plus } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 
 import { Button } from '@/components/ui/button'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
 
 import { useUsers } from './users-provider'
 
 export function UsersPrimaryButtons() {
   const { t } = useTranslation()
   const { setOpen, setCurrentRow } = useUsers()
+  const currentUser = useAuthStore((s) => s.auth.user)
+  const canCreateUser = hasPermissionCode(currentUser, PERMISSION_CODES.USER_CREATE)
 
   const handleCreate = () => {
     setCurrentRow(null)
@@ -34,10 +38,12 @@ export function UsersPrimaryButtons() {
 
   return (
     <div className='flex gap-2'>
-      <Button size='sm' onClick={handleCreate}>
-        <Plus className='h-4 w-4' />
-        {t('Add User')}
-      </Button>
+      {canCreateUser && (
+        <Button size='sm' onClick={handleCreate}>
+          <Plus className='h-4 w-4' />
+          {t('Add User')}
+        </Button>
+      )}
     </div>
   )
 }

+ 14 - 2
default/src/features/users/components/users-table.tsx

@@ -30,11 +30,11 @@ import {
 import { useMediaQuery } from '@/hooks'
 import { useTableUrlState } from '@/hooks/use-table-url-state'
 
+import { getRoles } from '../../roles/api'
 import { getUsers, searchUsers } from '../api'
 import {
   USER_STATUS,
   getUserStatusOptions,
-  getUserRoleOptions,
   isUserDeleted,
 } from '../constants'
 import type { User } from '../types'
@@ -54,6 +54,18 @@ export function UsersTable() {
   const { refreshTrigger } = useUsers()
   const isMobile = useMediaQuery('(max-width: 640px)')
 
+  const { data: rolesData } = useQuery({
+    queryKey: ['roles', 'users-filter'],
+    queryFn: () => getRoles({ p: 1, page_size: 100 }),
+    staleTime: 5 * 60 * 1000,
+  })
+
+  const roleOptions =
+    rolesData?.data?.items.map((role) => ({
+      value: String(role.id),
+      label: role.name || role.role_name || String(role.id),
+    })) || []
+
   const {
     globalFilter,
     onGlobalFilterChange,
@@ -187,7 +199,7 @@ export function UsersTable() {
           {
             columnId: 'role',
             title: t('Role'),
-            options: getUserRoleOptions(t),
+            options: roleOptions,
             singleSelect: true,
           },
         ],

+ 9 - 2
default/src/features/users/lib/user-form.ts

@@ -27,7 +27,7 @@ import { quotaUnitsToDollars } from '@/lib/format'
 import { ROLE } from '@/lib/roles'
 
 import { DEFAULT_GROUP } from '../constants'
-import { type UserFormData, type User } from '../types'
+import type { UserFormData, User } from '../types'
 
 // ============================================================================
 // Form Schema
@@ -83,6 +83,7 @@ export function transformFormDataToPayload(
   }
 
   const role = userId === undefined ? data.role || 1 : (data.role ?? 0)
+  const roleIds = role > 0 ? [role] : []
 
   // Only send the permission matrix when the target is an admin and the catalog
   // is available; without the catalog we cannot build a full matrix, so we omit
@@ -97,9 +98,11 @@ export function transformFormDataToPayload(
   // For create: only send required fields
   if (userId === undefined) {
     payload.role = role
+    payload.role_ids = roleIds
   } else {
     // For update: quota is adjusted atomically via /api/user/manage, not sent here
     payload.role = role
+    payload.role_ids = roleIds
     payload.group = data.group
     payload.remark = data.remark || undefined
     payload.id = userId
@@ -114,11 +117,15 @@ export function transformFormDataToPayload(
  * the catalog at render time in UsersMutateDrawer.
  */
 export function transformUserToFormDefaults(user: User): UserFormValues {
+  const relationRoleId = user.ext_user_relation_role?.find(
+    (relation) => relation.ext_role?.id
+  )?.ext_role?.id
+
   return {
     username: user.username,
     display_name: user.display_name,
     password: '',
-    role: user.role,
+    role: relationRoleId ?? user.role,
     quota_dollars: quotaUnitsToDollars(user.quota),
     group: user.group || DEFAULT_GROUP,
     remark: user.remark || '',

+ 15 - 0
default/src/features/users/types.ts

@@ -62,6 +62,20 @@ export const userSchema = z.object({
   admin_permissions: z
     .record(z.string(), z.record(z.string(), z.boolean()))
     .optional(),
+  ext_user_relation_role: z
+    .array(
+      z.object({
+        ext_role: z
+          .object({
+            role_name: z.string().optional(),
+            id: z.number().optional(),
+          })
+          .nullable()
+          .optional(),
+      })
+    )
+    .nullable()
+    .optional(),
 })
 export type User = z.infer<typeof userSchema>
 
@@ -108,6 +122,7 @@ export interface UserFormData {
   display_name: string
   password?: string
   role?: number // Only used when creating user
+  role_ids?: number[]
   quota?: number // Only used when updating user
   group?: string // Only used when updating user
   remark?: string // Only used when updating user

+ 14 - 1
default/src/hooks/use-sidebar-data.ts

@@ -32,11 +32,13 @@ import {
   // Ticket,
   User,
   Users,
+  ShieldCheck,
   // Wallet,
 } from 'lucide-react'
 // import { useTranslation } from 'react-i18next'
 
 import { type SidebarData } from '@/components/layout/types'
+import { PERMISSION_CODES } from '@/lib/admin-permissions'
 // import { ROLE } from '@/lib/roles'
 
 /**
@@ -74,6 +76,7 @@ export function useSidebarData(): SidebarData {
             title: '数据看板',
             url: '/dashboard/models',
             icon: LayoutDashboard,
+            permissionCode: PERMISSION_CODES.DASHBOARD_VIEW,
           },
         ],
       },
@@ -85,26 +88,31 @@ export function useSidebarData(): SidebarData {
             title: '渠道管理',
             url: '/channels',
             icon: Radio,
+            permissionCode: PERMISSION_CODES.CHANNEL_VIEW,
           },
           {
             title: '模型管理',
             url: '/models/metadata',
             icon: Box,
+            permissionCode: PERMISSION_CODES.MODELS_VIEW,
           },
           {
             title: '用户管理',
             url: '/users',
             icon: Users,
+            permissionCode: PERMISSION_CODES.USER_VIEW,
           },
           {
             title: '平台角色',
             url: '/roles',
-            icon: Users,
+            icon: ShieldCheck,
+            permissionCode: PERMISSION_CODES.ROLE_VIEW,
           },
           {
             title: '租户管理',
             url: '/tenants',
             icon: Building2,
+            permissionCode: PERMISSION_CODES.TENANT_VIEW,
           },
           // {
           //   title: t('Redemption Codes'),
@@ -132,16 +140,19 @@ export function useSidebarData(): SidebarData {
             title: '个人信息',
             url: '/profile',
             icon: User,
+            permissionCode: PERMISSION_CODES.PROFILE_VIEW,
           },
           {
             title: 'API密钥',
             url: '/keys',
             icon: Key,
+            permissionCode: PERMISSION_CODES.KEYS_VIEW,
           },
           {
             title: '模型列表',
             url: '/pricing',
             icon: Box,
+            permissionCode: PERMISSION_CODES.PRICING_VIEW,
           },
           // {
           //   title: t('Wallet'),
@@ -158,6 +169,7 @@ export function useSidebarData(): SidebarData {
             title: '调用日志',
             url: '/usage-logs/common',
             icon: FileText,
+            permissionCode: PERMISSION_CODES.USAGE_LOGS_VIEW,
           },
         ],
       },
@@ -170,6 +182,7 @@ export function useSidebarData(): SidebarData {
             url: '/system-settings/site',
             activeUrls: ['/system-settings'],
             icon: Settings,
+            permissionCode: PERMISSION_CODES.SETTINGS_VIEW,
           },
         ],
       },

+ 8 - 5
default/src/hooks/use-sidebar-view.ts

@@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next'
 
 import { resolveSidebarView } from '@/components/layout/lib/sidebar-view-registry'
 import type { NavGroup, ResolvedSidebarView } from '@/components/layout/types'
+import { hasPermissionCode } from '@/lib/admin-permissions'
 import { ROLE } from '@/lib/roles'
 import { useAuthStore } from '@/stores/auth-store'
 
@@ -47,22 +48,24 @@ const ROOT_VIEW_KEY = '__root'
 export function useSidebarView(): ResolvedSidebarView {
   const { t } = useTranslation()
   const pathname = useLocation({ select: (l) => l.pathname })
-  const userRole = useAuthStore((s) => s.auth.user?.role)
+  const currentUser = useAuthStore((s) => s.auth.user)
+  const userRole = currentUser?.role
   const rootSidebarData = useSidebarData()
   const configFilteredRoot = useSidebarConfig(rootSidebarData.navGroups)
 
   const rootNavGroups = useMemo<NavGroup[]>(() => {
     const role = userRole ?? ROLE.GUEST
-    const isAdmin = role >= ROLE.ADMIN
     return configFilteredRoot
-      .filter((group) => (group.id?.startsWith('admin') ? isAdmin : true))
       .map((group) => {
         const items = group.items.filter(
-          (item) => item.requiredRole === undefined || role >= item.requiredRole
+          (item) =>
+            (item.requiredRole === undefined || role >= item.requiredRole) &&
+            (!item.permissionCode || hasPermissionCode(currentUser, item.permissionCode))
         )
         return items.length === group.items.length ? group : { ...group, items }
       })
-  }, [configFilteredRoot, userRole])
+      .filter((group) => group.items.length > 0)
+  }, [configFilteredRoot, currentUser, userRole])
 
   const view = resolveSidebarView(pathname)
 

+ 1 - 1
default/src/i18n/locales/zh.json

@@ -1199,7 +1199,7 @@
     "Current Value": "当前值",
     "Current version": "当前版本",
     "Current:": "当前:",
-    "Custom": "自定义",
+    "Custom": "OpenaiApiCompatible",
     "Custom (seconds)": "自定义(秒)",
     "Custom Amount": "自定义金额",
     "Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.": "自定义 API 基础 URL。对于官方渠道,New API 具有内置地址。仅针对第三方代理站点或特殊端点填写此项。请勿添加 /v1 或尾部斜杠。",

+ 45 - 0
default/src/lib/admin-permissions.ts

@@ -17,6 +17,42 @@ export const ADMIN_PERMISSION_ACTIONS = {
   SECRET_VIEW: 'secret_view',
 } as const
 
+export const PERMISSION_CODES = {
+  DASHBOARD_VIEW: 'dashboard.view',
+  CHANNEL_VIEW: 'channel.view',
+  CHANNEL_CREATE: 'channel.create',
+  CHANNEL_UPDATE: 'channel.update',
+  CHANNEL_DELETE: 'channel.delete',
+  CHANNEL_DISABLE: 'channel.disable',
+  MODELS_VIEW: 'models.view',
+  MODELS_CREATE: 'models.create',
+  MODELS_UPDATE: 'models.update',
+  MODELS_DELETE: 'models.delete',
+  MODELS_DISABLE: 'models.disable',
+  USER_VIEW: 'user.view',
+  USER_CREATE: 'user.create',
+  USER_UPDATE: 'user.update',
+  USER_DELETE: 'user.delete',
+  USER_DISABLE: 'user.disable',
+  ROLE_VIEW: 'role.view',
+  ROLE_CREATE: 'role.create',
+  ROLE_UPDATE: 'role.update',
+  ROLE_DELETE: 'role.delete',
+  TENANT_VIEW: 'tenant.view',
+  TENANT_CREATE: 'tenant.create',
+  TENANT_UPDATE: 'tenant.update',
+  TENANT_DISABLE: 'tenant.disable',
+  PROFILE_VIEW: 'profile.view',
+  KEYS_VIEW: 'keys.view',
+  KEYS_CREATE: 'keys.create',
+  KEYS_UPDATE: 'keys.update',
+  KEYS_DELETE: 'keys.delete',
+  KEYS_DISABLE: 'keys.disable',
+  PRICING_VIEW: 'pricing.view',
+  USAGE_LOGS_VIEW: 'usage-logs.view',
+  SETTINGS_VIEW: 'settings.view',
+} as const
+
 // The role whose baseline grants are used as defaults in the permission editor.
 export const ADMIN_ROLE_KEY = 'admin'
 
@@ -64,6 +100,15 @@ export function hasPermission(
   return user.permissions?.admin_permissions?.[resource]?.[action] === true
 }
 
+export function hasPermissionCode(
+  user: AuthUser | null | undefined,
+  code: string
+): boolean {
+  if (!user) return false
+  if (user.role === ROLE.SUPER_ADMIN) return true
+  return user.permissions?.codes?.includes(code) === true
+}
+
 // roleGrants returns the baseline grant matrix for the given role key.
 export function roleGrants(
   catalog: PermissionCatalog,

+ 22 - 0
default/src/routeTree.gen.ts

@@ -51,6 +51,7 @@ import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenti
 import { Route as AuthenticatedKeysIndexRouteImport } from './routes/_authenticated/keys/index'
 import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index'
 import { Route as AuthenticatedChannelsIndexRouteImport } from './routes/_authenticated/channels/index'
+import { Route as TokenTenantUniqueIdRegisterRouteImport } from './routes/token/$tenantUniqueId/register'
 import { Route as AuthenticatedUsageLogsSectionRouteImport } from './routes/_authenticated/usage-logs/$section'
 import { Route as AuthenticatedModelsSectionRouteImport } from './routes/_authenticated/models/$section'
 import { Route as AuthenticatedErrorsErrorRouteImport } from './routes/_authenticated/errors/$error'
@@ -293,6 +294,12 @@ const AuthenticatedChannelsIndexRoute =
     path: '/channels/',
     getParentRoute: () => AuthenticatedRouteRoute,
   } as any)
+const TokenTenantUniqueIdRegisterRoute =
+  TokenTenantUniqueIdRegisterRouteImport.update({
+    id: '/token/$tenantUniqueId/register',
+    path: '/token/$tenantUniqueId/register',
+    getParentRoute: () => rootRouteImport,
+  } as any)
 const AuthenticatedUsageLogsSectionRoute =
   AuthenticatedUsageLogsSectionRouteImport.update({
     id: '/usage-logs/$section',
@@ -443,6 +450,7 @@ export interface FileRoutesByFullPath {
   '/errors/$error': typeof AuthenticatedErrorsErrorRoute
   '/models/$section': typeof AuthenticatedModelsSectionRoute
   '/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
+  '/token/$tenantUniqueId/register': typeof TokenTenantUniqueIdRegisterRoute
   '/channels/': typeof AuthenticatedChannelsIndexRoute
   '/dashboard/': typeof AuthenticatedDashboardIndexRoute
   '/keys/': typeof AuthenticatedKeysIndexRoute
@@ -504,6 +512,7 @@ export interface FileRoutesByTo {
   '/errors/$error': typeof AuthenticatedErrorsErrorRoute
   '/models/$section': typeof AuthenticatedModelsSectionRoute
   '/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
+  '/token/$tenantUniqueId/register': typeof TokenTenantUniqueIdRegisterRoute
   '/channels': typeof AuthenticatedChannelsIndexRoute
   '/dashboard': typeof AuthenticatedDashboardIndexRoute
   '/keys': typeof AuthenticatedKeysIndexRoute
@@ -569,6 +578,7 @@ export interface FileRoutesById {
   '/_authenticated/errors/$error': typeof AuthenticatedErrorsErrorRoute
   '/_authenticated/models/$section': typeof AuthenticatedModelsSectionRoute
   '/_authenticated/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
+  '/token/$tenantUniqueId/register': typeof TokenTenantUniqueIdRegisterRoute
   '/_authenticated/channels/': typeof AuthenticatedChannelsIndexRoute
   '/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute
   '/_authenticated/keys/': typeof AuthenticatedKeysIndexRoute
@@ -633,6 +643,7 @@ export interface FileRouteTypes {
     | '/errors/$error'
     | '/models/$section'
     | '/usage-logs/$section'
+    | '/token/$tenantUniqueId/register'
     | '/channels/'
     | '/dashboard/'
     | '/keys/'
@@ -694,6 +705,7 @@ export interface FileRouteTypes {
     | '/errors/$error'
     | '/models/$section'
     | '/usage-logs/$section'
+    | '/token/$tenantUniqueId/register'
     | '/channels'
     | '/dashboard'
     | '/keys'
@@ -758,6 +770,7 @@ export interface FileRouteTypes {
     | '/_authenticated/errors/$error'
     | '/_authenticated/models/$section'
     | '/_authenticated/usage-logs/$section'
+    | '/token/$tenantUniqueId/register'
     | '/_authenticated/channels/'
     | '/_authenticated/dashboard/'
     | '/_authenticated/keys/'
@@ -808,6 +821,7 @@ export interface RootRouteChildren {
   PricingIndexRoute: typeof PricingIndexRoute
   RankingsIndexRoute: typeof RankingsIndexRoute
   SetupIndexRoute: typeof SetupIndexRoute
+  TokenTenantUniqueIdRegisterRoute: typeof TokenTenantUniqueIdRegisterRoute
   PricingModelIdIndexRoute: typeof PricingModelIdIndexRoute
 }
 
@@ -1107,6 +1121,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof AuthenticatedChannelsIndexRouteImport
       parentRoute: typeof AuthenticatedRouteRoute
     }
+    '/token/$tenantUniqueId/register': {
+      id: '/token/$tenantUniqueId/register'
+      path: '/token/$tenantUniqueId/register'
+      fullPath: '/token/$tenantUniqueId/register'
+      preLoaderRoute: typeof TokenTenantUniqueIdRegisterRouteImport
+      parentRoute: typeof rootRouteImport
+    }
     '/_authenticated/usage-logs/$section': {
       id: '/_authenticated/usage-logs/$section'
       path: '/usage-logs/$section'
@@ -1404,6 +1425,7 @@ const rootRouteChildren: RootRouteChildren = {
   PricingIndexRoute: PricingIndexRoute,
   RankingsIndexRoute: RankingsIndexRoute,
   SetupIndexRoute: SetupIndexRoute,
+  TokenTenantUniqueIdRegisterRoute: TokenTenantUniqueIdRegisterRoute,
   PricingModelIdIndexRoute: PricingModelIdIndexRoute,
 }
 export const routeTree = rootRouteImport

+ 2 - 2
default/src/routes/_authenticated/channels/index.tsx

@@ -20,7 +20,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
 import z from 'zod'
 
 import { Channels } from '@/features/channels'
-import { ROLE } from '@/lib/roles'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
 import { useAuthStore } from '@/stores/auth-store'
 
 const channelsSearchSchema = z.object({
@@ -37,7 +37,7 @@ export const Route = createFileRoute('/_authenticated/channels/')({
   beforeLoad: () => {
     const { auth } = useAuthStore.getState()
 
-    if (!auth.user || auth.user.role < ROLE.ADMIN) {
+    if (!hasPermissionCode(auth.user, PERMISSION_CODES.CHANNEL_VIEW)) {
       throw redirect({
         to: '/403',
       })

+ 2 - 2
default/src/routes/_authenticated/models/$section.tsx

@@ -24,7 +24,7 @@ import {
   MODELS_SECTION_IDS,
   MODELS_DEFAULT_SECTION,
 } from '@/features/models/section-registry'
-import { ROLE } from '@/lib/roles'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
 import { useAuthStore } from '@/stores/auth-store'
 
 const modelsSearchSchema = z.object({
@@ -44,7 +44,7 @@ export const Route = createFileRoute('/_authenticated/models/$section')({
   beforeLoad: ({ params }) => {
     const { auth } = useAuthStore.getState()
 
-    if (!auth.user || auth.user.role < ROLE.ADMIN) {
+    if (!hasPermissionCode(auth.user, PERMISSION_CODES.MODELS_VIEW)) {
       throw redirect({
         to: '/403',
       })

+ 2 - 2
default/src/routes/_authenticated/models/index.tsx

@@ -19,14 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
 import { createFileRoute, redirect } from '@tanstack/react-router'
 
 import { MODELS_DEFAULT_SECTION } from '@/features/models/section-registry'
-import { ROLE } from '@/lib/roles'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
 import { useAuthStore } from '@/stores/auth-store'
 
 export const Route = createFileRoute('/_authenticated/models/')({
   beforeLoad: () => {
     const { auth } = useAuthStore.getState()
 
-    if (!auth.user || auth.user.role < ROLE.ADMIN) {
+    if (!hasPermissionCode(auth.user, PERMISSION_CODES.MODELS_VIEW)) {
       throw redirect({
         to: '/403',
       })

+ 11 - 1
default/src/routes/_authenticated/route.tsx

@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
 import { createFileRoute, redirect } from '@tanstack/react-router'
 
 import { AuthenticatedLayout } from '@/components/layout'
+import { getCurrentPermissionsWithRole } from '@/features/permissions/api'
 import { getSelf } from '@/lib/api'
 import { useAuthStore } from '@/stores/auth-store'
 
@@ -47,7 +48,16 @@ export const Route = createFileRoute('/_authenticated')({
       )
       if (res?.success && res.data) {
         // 验证成功,更新用户信息(可能有变化)
-        auth.setUser(res.data)
+        const permissions = await getCurrentPermissionsWithRole().catch(() =>
+          auth.user?.permissions?.codes ? { codes: auth.user.permissions.codes } : { codes: [] }
+        )
+        auth.setUser({
+          ...res.data,
+          permissions: {
+            ...res.data.permissions,
+            ...permissions,
+          },
+        })
         sessionVerified = true
       } else if (res) {
         // 验证失败,清除本地缓存并跳转登录页

+ 2 - 2
default/src/routes/_authenticated/tenants/index.tsx

@@ -19,14 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
 import { createFileRoute, redirect } from '@tanstack/react-router'
 
 import { Tenants } from '@/features/tenants'
-import { ROLE } from '@/lib/roles'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
 import { useAuthStore } from '@/stores/auth-store'
 
 export const Route = createFileRoute('/_authenticated/tenants/')({
   beforeLoad: () => {
     const { auth } = useAuthStore.getState()
 
-    if (!auth.user || auth.user.role < ROLE.ADMIN) {
+    if (!hasPermissionCode(auth.user, PERMISSION_CODES.TENANT_VIEW)) {
       throw redirect({
         to: '/403',
       })

+ 2 - 2
default/src/routes/_authenticated/users/index.tsx

@@ -20,7 +20,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
 import z from 'zod'
 
 import { Users } from '@/features/users'
-import { ROLE } from '@/lib/roles'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
 import { useAuthStore } from '@/stores/auth-store'
 
 const usersSearchSchema = z.object({
@@ -42,7 +42,7 @@ export const Route = createFileRoute('/_authenticated/users/')({
   beforeLoad: () => {
     const { auth } = useAuthStore.getState()
 
-    if (!auth.user || auth.user.role < ROLE.ADMIN) {
+    if (!hasPermissionCode(auth.user, PERMISSION_CODES.USER_VIEW)) {
       throw redirect({
         to: '/403',
       })

+ 25 - 0
default/src/routes/token/$tenantUniqueId/register.tsx

@@ -0,0 +1,25 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { createFileRoute } from '@tanstack/react-router'
+
+import { SignUp } from '@/features/auth/sign-up'
+
+export const Route = createFileRoute('/token/$tenantUniqueId/register')({
+  component: SignUp,
+})

+ 1 - 0
default/src/stores/auth-store.ts

@@ -24,6 +24,7 @@ export type UserPermissions = {
   sidebar_settings?: boolean
   sidebar_modules?: Record<string, unknown>
   admin_permissions?: AdminCapabilities
+  codes?: string[]
 }
 
 export interface AuthUser {