use-sidebar-config.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import { useMemo } from 'react'
  2. import { useAuthStore } from '@/stores/auth-store'
  3. import { useStatus } from '@/hooks/use-status'
  4. import type { NavGroup, NavItem } from '@/components/layout/types'
  5. type SidebarSectionConfig = {
  6. enabled: boolean
  7. [key: string]: boolean
  8. }
  9. type SidebarModulesAdminConfig = Record<string, SidebarSectionConfig>
  10. // User-layer config is shape-identical to admin, but may be null
  11. // to signal "no narrowing" (empty/invalid/legacy users).
  12. type SidebarModulesUserConfig = SidebarModulesAdminConfig | null
  13. /**
  14. * Default sidebar modules configuration
  15. */
  16. const DEFAULT_SIDEBAR_MODULES: SidebarModulesAdminConfig = {
  17. chat: {
  18. enabled: true,
  19. playground: true,
  20. chat: true,
  21. },
  22. console: {
  23. enabled: true,
  24. detail: true,
  25. token: true,
  26. log: true,
  27. midjourney: true,
  28. task: true,
  29. },
  30. personal: {
  31. enabled: true,
  32. topup: true,
  33. personal: true,
  34. },
  35. admin: {
  36. enabled: true,
  37. channel: true,
  38. models: true,
  39. redemption: true,
  40. user: true,
  41. setting: true,
  42. subscription: true,
  43. },
  44. }
  45. /**
  46. * Mapping from URL to configuration keys
  47. */
  48. const URL_TO_CONFIG_MAP: Record<string, { section: string; module: string }> = {
  49. '/playground': { section: 'chat', module: 'playground' },
  50. '/dashboard': { section: 'console', module: 'detail' },
  51. '/dashboard/overview': { section: 'console', module: 'detail' },
  52. '/dashboard/models': { section: 'console', module: 'detail' },
  53. '/dashboard/users': { section: 'console', module: 'detail' },
  54. '/keys': { section: 'console', module: 'token' },
  55. '/usage-logs': { section: 'console', module: 'log' },
  56. '/usage-logs/common': { section: 'console', module: 'log' },
  57. '/usage-logs/drawing': { section: 'console', module: 'midjourney' },
  58. '/usage-logs/task': { section: 'console', module: 'task' },
  59. '/wallet': { section: 'personal', module: 'topup' },
  60. '/profile': { section: 'personal', module: 'personal' },
  61. '/channels': { section: 'admin', module: 'channel' },
  62. '/models': { section: 'admin', module: 'models' },
  63. '/models/metadata': { section: 'admin', module: 'models' },
  64. '/models/deployments': { section: 'admin', module: 'models' },
  65. '/users': { section: 'admin', module: 'user' },
  66. '/redemption-codes': { section: 'admin', module: 'redemption' },
  67. '/subscriptions': { section: 'admin', module: 'subscription' },
  68. '/system-settings': { section: 'admin', module: 'setting' },
  69. '/system-settings/general': { section: 'admin', module: 'setting' },
  70. }
  71. /**
  72. * Parse backend SidebarModulesAdmin configuration
  73. */
  74. function parseSidebarConfig(
  75. value: string | null | undefined
  76. ): SidebarModulesAdminConfig {
  77. // If empty string, null, or undefined, use default config
  78. if (!value || value.trim() === '') {
  79. return DEFAULT_SIDEBAR_MODULES
  80. }
  81. try {
  82. const parsed = JSON.parse(value) as SidebarModulesAdminConfig
  83. // Ensure chat section and its modules are correctly initialized if missing
  84. if (!parsed.chat) {
  85. parsed.chat = { enabled: true, playground: true, chat: true }
  86. } else {
  87. if (parsed.chat.enabled === undefined) parsed.chat.enabled = true
  88. if (parsed.chat.playground === undefined) parsed.chat.playground = true
  89. if (parsed.chat.chat === undefined) parsed.chat.chat = true
  90. }
  91. return parsed
  92. } catch {
  93. // eslint-disable-next-line no-console
  94. console.error('Failed to parse sidebar modules configuration')
  95. return DEFAULT_SIDEBAR_MODULES
  96. }
  97. }
  98. /**
  99. * Parse user-level sidebar_modules. Returns null when the value is empty,
  100. * invalid, or otherwise unusable — the caller treats null as "do not narrow",
  101. * so legacy users with an empty sidebar_modules field keep the full admin view.
  102. */
  103. function parseUserSidebarConfig(
  104. value: string | null | undefined
  105. ): SidebarModulesUserConfig {
  106. if (!value || value.trim() === '') {
  107. return null
  108. }
  109. try {
  110. const parsed = JSON.parse(value) as SidebarModulesAdminConfig
  111. if (!parsed || typeof parsed !== 'object') return null
  112. return parsed
  113. } catch {
  114. return null
  115. }
  116. }
  117. /**
  118. * Check if a module is enabled. Admin config is the first (authoritative)
  119. * layer: if admin disables a section/module it is always hidden. User config
  120. * is a second narrower layer: it can only further hide what admin allowed.
  121. * A null user config means "do not narrow" (legacy/empty users).
  122. */
  123. function isModuleEnabled(
  124. url: string,
  125. adminConfig: SidebarModulesAdminConfig,
  126. userConfig: SidebarModulesUserConfig
  127. ): boolean {
  128. const mapping = URL_TO_CONFIG_MAP[url]
  129. if (!mapping) {
  130. // No mapping config, default to visible (e.g. system settings and new features)
  131. return true
  132. }
  133. const { section, module } = mapping
  134. const adminSection = adminConfig[section]
  135. const adminAllowed = Boolean(
  136. adminSection && adminSection.enabled && adminSection[module] === true
  137. )
  138. if (!adminAllowed) return false
  139. if (!userConfig) return true
  140. const userSection = userConfig[section]
  141. if (!userSection) return true
  142. if (userSection.enabled === false) return false
  143. return userSection[module] !== false
  144. }
  145. /**
  146. * Check if a navigation item should be visible
  147. */
  148. function isNavItemVisible(
  149. item: NavItem,
  150. adminConfig: SidebarModulesAdminConfig,
  151. userConfig: SidebarModulesUserConfig
  152. ): boolean {
  153. // Handle dynamic chat presets type — also runs the admin × user AND gate
  154. if ('type' in item && item.type === 'chat-presets') {
  155. const adminChat = adminConfig.chat
  156. const adminAllowed = Boolean(adminChat?.enabled && adminChat.chat === true)
  157. if (!adminAllowed) return false
  158. if (!userConfig) return true
  159. const userChat = userConfig.chat
  160. if (!userChat) return true
  161. if (userChat.enabled === false) return false
  162. return userChat.chat !== false
  163. }
  164. // Handle direct link type
  165. if ('url' in item && item.url) {
  166. const configUrls = item.configUrls ?? [item.url]
  167. return configUrls.some((url) =>
  168. isModuleEnabled(url as string, adminConfig, userConfig)
  169. )
  170. }
  171. // Handle collapsible type (with sub-items)
  172. if ('items' in item && item.items) {
  173. // If has sub-items, show this collapsible item if at least one sub-item is visible
  174. return item.items.some((subItem) =>
  175. isModuleEnabled(subItem.url as string, adminConfig, userConfig)
  176. )
  177. }
  178. return true
  179. }
  180. /**
  181. * Filter navigation items
  182. */
  183. function filterNavItems(
  184. items: NavItem[],
  185. adminConfig: SidebarModulesAdminConfig,
  186. userConfig: SidebarModulesUserConfig
  187. ): NavItem[] {
  188. return items
  189. .map((item) => {
  190. // If collapsible item, also filter its sub-items
  191. if ('items' in item && item.items) {
  192. const filteredSubItems = item.items.filter((subItem) =>
  193. isModuleEnabled(subItem.url as string, adminConfig, userConfig)
  194. )
  195. return {
  196. ...item,
  197. items: filteredSubItems,
  198. }
  199. }
  200. return item
  201. })
  202. .filter((item) => isNavItemVisible(item, adminConfig, userConfig))
  203. }
  204. /**
  205. * Filter sidebar navigation groups by admin × user sidebar_modules config.
  206. *
  207. * Two layers, AND-combined:
  208. * 1. Admin (status.SidebarModulesAdmin) — authoritative, falls back to
  209. * DEFAULT_SIDEBAR_MODULES when empty/invalid. Disabling here hides the
  210. * item for everyone regardless of user preference.
  211. * 2. User (auth.user.sidebar_modules) — narrower overlay, null sentinel
  212. * means "don't narrow". A section/module is only hidden if the user
  213. * explicitly set it to false; undefined fields default to visible so
  214. * legacy users with empty sidebar_modules keep the full admin view.
  215. * The overlay is also skipped entirely when the backend tells us the
  216. * user cannot configure sidebar_settings (e.g. root accounts), so a
  217. * stale historical value cannot lock them out of entries they have no
  218. * UI to restore.
  219. */
  220. export function useSidebarConfig(navGroups: NavGroup[]): NavGroup[] {
  221. const { status } = useStatus()
  222. const { auth } = useAuthStore()
  223. const adminConfig = useMemo(
  224. () =>
  225. parseSidebarConfig(
  226. status?.SidebarModulesAdmin as string | null | undefined
  227. ),
  228. [status?.SidebarModulesAdmin]
  229. )
  230. const userConfig = useMemo(() => {
  231. // If the backend marks the user as unable to configure the sidebar
  232. // (e.g. root accounts), skip the user overlay entirely — a stale
  233. // historical sidebar_modules value from a previous role would otherwise
  234. // hide admin entries for someone who has no in-product UI to restore
  235. // them.
  236. if (auth?.user?.permissions?.sidebar_settings === false) {
  237. return null
  238. }
  239. return parseUserSidebarConfig(auth?.user?.sidebar_modules)
  240. }, [auth?.user?.permissions?.sidebar_settings, auth?.user?.sidebar_modules])
  241. const filteredNavGroups = useMemo(
  242. () =>
  243. navGroups
  244. .map((group) => ({
  245. ...group,
  246. items: filterNavItems(group.items, adminConfig, userConfig),
  247. }))
  248. .filter((group) => group.items.length > 0), // Only show navigation groups with visible items
  249. [navGroups, adminConfig, userConfig]
  250. )
  251. return filteredNavGroups
  252. }