@fayz-ai/saas 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/dist/access/context.d.ts +47 -0
  2. package/dist/access/context.d.ts.map +1 -0
  3. package/dist/access/headless.d.ts +21 -0
  4. package/dist/access/headless.d.ts.map +1 -0
  5. package/dist/access/index.d.ts +12 -0
  6. package/dist/access/index.d.ts.map +1 -0
  7. package/dist/access/limits-registry.d.ts +33 -0
  8. package/dist/access/limits-registry.d.ts.map +1 -0
  9. package/dist/access/module-nav.d.ts +15 -0
  10. package/dist/access/module-nav.d.ts.map +1 -0
  11. package/dist/access/resolver.d.ts +2 -0
  12. package/dist/access/resolver.d.ts.map +1 -0
  13. package/dist/access/types.d.ts +43 -0
  14. package/dist/access/types.d.ts.map +1 -0
  15. package/dist/access/upgrade-modal-store.d.ts +23 -0
  16. package/dist/access/upgrade-modal-store.d.ts.map +1 -0
  17. package/dist/app/AdminShell.d.ts +3 -0
  18. package/dist/app/AdminShell.d.ts.map +1 -1
  19. package/dist/app/admin-app.d.ts +3 -1
  20. package/dist/app/admin-app.d.ts.map +1 -1
  21. package/dist/app/config.d.ts +47 -2
  22. package/dist/app/config.d.ts.map +1 -1
  23. package/dist/app/core-entities.d.ts +25 -0
  24. package/dist/app/core-entities.d.ts.map +1 -0
  25. package/dist/app/derive-contract.d.ts +8 -0
  26. package/dist/app/derive-contract.d.ts.map +1 -0
  27. package/dist/app/scaffold.d.ts.map +1 -1
  28. package/dist/archetype-lookup.d.ts.map +1 -1
  29. package/dist/billing/index.cjs +6 -2
  30. package/dist/billing/index.d.ts +2 -0
  31. package/dist/billing/index.d.ts.map +1 -1
  32. package/dist/billing/index.js +1 -1
  33. package/dist/billing/plan-badge.d.ts +17 -0
  34. package/dist/billing/plan-badge.d.ts.map +1 -0
  35. package/dist/billing/store.d.ts +6 -1
  36. package/dist/billing/store.d.ts.map +1 -1
  37. package/dist/{chunk-3M25GRRU.js → chunk-I3DY47N5.js} +3 -4
  38. package/dist/chunk-I3DY47N5.js.map +1 -0
  39. package/dist/chunk-NNFGMUF2.cjs +39 -0
  40. package/dist/chunk-NNFGMUF2.cjs.map +1 -0
  41. package/dist/{chunk-4XTQ4V5L.cjs → chunk-SAZLW54Y.cjs} +16 -12
  42. package/dist/chunk-SAZLW54Y.cjs.map +1 -0
  43. package/dist/{chunk-IPWCHJPE.cjs → chunk-VKYZACG5.cjs} +5 -6
  44. package/dist/chunk-VKYZACG5.cjs.map +1 -0
  45. package/dist/chunk-VN5NTK26.js +36 -0
  46. package/dist/chunk-VN5NTK26.js.map +1 -0
  47. package/dist/{chunk-XNWCGJYH.js → chunk-VTEP2AQX.js} +15 -11
  48. package/dist/chunk-VTEP2AQX.js.map +1 -0
  49. package/dist/components/shared/ContactPicker.d.ts +92 -0
  50. package/dist/components/shared/ContactPicker.d.ts.map +1 -0
  51. package/dist/components/shared/PersonLink.d.ts +12 -1
  52. package/dist/components/shared/PersonLink.d.ts.map +1 -1
  53. package/dist/components/shared/create-person.d.ts +20 -0
  54. package/dist/components/shared/create-person.d.ts.map +1 -0
  55. package/dist/crud/CrudDetailPage.d.ts.map +1 -1
  56. package/dist/crud/CrudFormPage.d.ts.map +1 -1
  57. package/dist/crud/CrudListView.d.ts.map +1 -1
  58. package/dist/crud/CrudPage.d.ts.map +1 -1
  59. package/dist/crud/archetypes/AddressesTab.d.ts +21 -0
  60. package/dist/crud/archetypes/AddressesTab.d.ts.map +1 -0
  61. package/dist/crud/archetypes/PersonDetailTabs.d.ts +2 -1
  62. package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
  63. package/dist/crud/createCrudPage.d.ts.map +1 -1
  64. package/dist/crud/index.d.ts +1 -0
  65. package/dist/crud/index.d.ts.map +1 -1
  66. package/dist/index.cjs +4281 -716
  67. package/dist/index.cjs.map +1 -1
  68. package/dist/index.d.ts +17 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4067 -542
  71. package/dist/index.js.map +1 -1
  72. package/dist/org/index.cjs +11 -11
  73. package/dist/org/index.js +2 -2
  74. package/dist/org/store.d.ts.map +1 -1
  75. package/dist/permissions/context.d.ts +2 -0
  76. package/dist/permissions/context.d.ts.map +1 -1
  77. package/dist/permissions/index.cjs +7 -7
  78. package/dist/permissions/index.js +1 -1
  79. package/dist/shell/components/auth/ProtectedRoute.d.ts.map +1 -1
  80. package/dist/shell/components/billing/SoftLimitBanner.d.ts +5 -0
  81. package/dist/shell/components/billing/SoftLimitBanner.d.ts.map +1 -0
  82. package/dist/shell/components/billing/SubscriptionPage.d.ts +5 -0
  83. package/dist/shell/components/billing/SubscriptionPage.d.ts.map +1 -0
  84. package/dist/shell/components/billing/UpgradeModal.d.ts +3 -0
  85. package/dist/shell/components/billing/UpgradeModal.d.ts.map +1 -0
  86. package/dist/shell/components/billing/UpgradePrompt.d.ts +10 -0
  87. package/dist/shell/components/billing/UpgradePrompt.d.ts.map +1 -0
  88. package/dist/shell/components/billing/access-contract.d.ts +18 -0
  89. package/dist/shell/components/billing/access-contract.d.ts.map +1 -0
  90. package/dist/shell/components/billing/gates.d.ts +14 -0
  91. package/dist/shell/components/billing/gates.d.ts.map +1 -0
  92. package/dist/shell/components/billing/index.d.ts +9 -2
  93. package/dist/shell/components/billing/index.d.ts.map +1 -1
  94. package/dist/shell/components/chat/ChatFab.d.ts +3 -1
  95. package/dist/shell/components/chat/ChatFab.d.ts.map +1 -1
  96. package/dist/shell/components/chat/ChatPanel.d.ts +3 -1
  97. package/dist/shell/components/chat/ChatPanel.d.ts.map +1 -1
  98. package/dist/shell/components/chat/ConfirmActionCard.d.ts +13 -0
  99. package/dist/shell/components/chat/ConfirmActionCard.d.ts.map +1 -0
  100. package/dist/shell/components/chat/markdown.d.ts +5 -0
  101. package/dist/shell/components/chat/markdown.d.ts.map +1 -0
  102. package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
  103. package/dist/shell/components/organization/TenantOnboarding.d.ts.map +1 -1
  104. package/dist/shell/components/organization/index.d.ts +0 -1
  105. package/dist/shell/components/organization/index.d.ts.map +1 -1
  106. package/dist/shell/components/settings/ConnectedCompanySettings.d.ts.map +1 -1
  107. package/dist/shell/components/settings/ConnectedFieldRulesSettings.d.ts.map +1 -1
  108. package/dist/shell/components/settings/ConnectedSecuritySettings.d.ts.map +1 -1
  109. package/dist/shell/components/settings/LocationsCrudPage.d.ts +1 -1
  110. package/dist/shell/components/settings/LocationsCrudPage.d.ts.map +1 -1
  111. package/dist/shell/components/settings/SettingsPage.d.ts +23 -2
  112. package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
  113. package/dist/shell/hooks/index.d.ts +3 -2
  114. package/dist/shell/hooks/index.d.ts.map +1 -1
  115. package/dist/shell/hooks/useAITools.d.ts +2 -0
  116. package/dist/shell/hooks/useAITools.d.ts.map +1 -1
  117. package/dist/shell/hooks/useChat.d.ts +22 -0
  118. package/dist/shell/hooks/useChat.d.ts.map +1 -1
  119. package/dist/shell/hooks/useSystemPermission.d.ts +3 -0
  120. package/dist/shell/hooks/useSystemPermission.d.ts.map +1 -0
  121. package/dist/shell/hooks/useTenantPluginSettings.d.ts +12 -0
  122. package/dist/shell/hooks/useTenantPluginSettings.d.ts.map +1 -0
  123. package/dist/shell/lib/ai-tool-handlers.d.ts +119 -0
  124. package/dist/shell/lib/ai-tool-handlers.d.ts.map +1 -0
  125. package/dist/shell/lib/core-ai-tools.d.ts +14 -0
  126. package/dist/shell/lib/core-ai-tools.d.ts.map +1 -1
  127. package/dist/shell/lib/create-client-orders-provider.d.ts.map +1 -1
  128. package/dist/shell/lib/fayz-agent.d.ts +23 -0
  129. package/dist/shell/lib/fayz-agent.d.ts.map +1 -0
  130. package/dist/shell/lib/i18n.d.ts.map +1 -1
  131. package/dist/shell/stores/chat.store.d.ts +69 -0
  132. package/dist/shell/stores/chat.store.d.ts.map +1 -1
  133. package/dist/shell/types/billing.d.ts +7 -0
  134. package/dist/shell/types/billing.d.ts.map +1 -1
  135. package/dist/shell/types/plugins.d.ts +2 -45
  136. package/dist/shell/types/plugins.d.ts.map +1 -1
  137. package/dist/stores/createCrudStore.d.ts.map +1 -1
  138. package/package.json +14 -11
  139. package/src/access/context.tsx +228 -0
  140. package/src/access/headless.ts +71 -0
  141. package/src/access/index.ts +27 -0
  142. package/src/access/limits-registry.ts +86 -0
  143. package/src/access/module-nav.test.ts +78 -0
  144. package/src/access/module-nav.ts +82 -0
  145. package/src/access/resolver.test.ts +143 -0
  146. package/src/access/resolver.ts +5 -0
  147. package/src/access/types.ts +47 -0
  148. package/src/access/upgrade-modal-store.ts +30 -0
  149. package/src/app/AdminShell.tsx +451 -68
  150. package/src/app/admin-app.tsx +46 -9
  151. package/src/app/config.ts +54 -1
  152. package/src/app/core-entities.ts +110 -0
  153. package/src/app/derive-contract.test.ts +155 -0
  154. package/src/app/derive-contract.ts +214 -0
  155. package/src/app/scaffold.tsx +4 -0
  156. package/src/archetype-lookup.ts +8 -2
  157. package/src/billing/index.ts +2 -0
  158. package/src/billing/plan-badge.ts +32 -0
  159. package/src/billing/store.ts +8 -1
  160. package/src/components/shared/ContactPicker.tsx +394 -0
  161. package/src/components/shared/PersonLink.tsx +11 -3
  162. package/src/components/shared/create-person.ts +74 -0
  163. package/src/crud/CrudDetailPage.tsx +5 -1
  164. package/src/crud/CrudFormPage.tsx +113 -6
  165. package/src/crud/CrudListView.tsx +16 -1
  166. package/src/crud/CrudPage.tsx +27 -5
  167. package/src/crud/DeleteConfirmDialog.tsx +1 -1
  168. package/src/crud/archetypes/AddressesTab.tsx +387 -0
  169. package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
  170. package/src/crud/createCrudPage.tsx +2 -0
  171. package/src/crud/index.ts +6 -0
  172. package/src/env.d.ts +7 -0
  173. package/src/index.ts +49 -1
  174. package/src/org/store.ts +9 -1
  175. package/src/permissions/context.tsx +29 -15
  176. package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
  177. package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
  178. package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
  179. package/src/shell/components/billing/UpgradeModal.tsx +102 -0
  180. package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
  181. package/src/shell/components/billing/access-contract.ts +64 -0
  182. package/src/shell/components/billing/gates.tsx +62 -0
  183. package/src/shell/components/billing/index.ts +18 -2
  184. package/src/shell/components/chat/ChatFab.tsx +4 -2
  185. package/src/shell/components/chat/ChatPanel.tsx +174 -23
  186. package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
  187. package/src/shell/components/chat/markdown.tsx +102 -0
  188. package/src/shell/components/organization/TeamTab.tsx +21 -4
  189. package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
  190. package/src/shell/components/organization/index.ts +0 -1
  191. package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
  192. package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
  193. package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
  194. package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
  195. package/src/shell/components/settings/SettingsPage.tsx +63 -21
  196. package/src/shell/hooks/index.ts +3 -2
  197. package/src/shell/hooks/useAITools.ts +27 -23
  198. package/src/shell/hooks/useChat.ts +381 -16
  199. package/src/shell/hooks/useSystemPermission.ts +23 -0
  200. package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
  201. package/src/shell/lib/ai-tool-handlers.ts +910 -0
  202. package/src/shell/lib/core-ai-tools.ts +234 -2
  203. package/src/shell/lib/create-client-orders-provider.ts +24 -2
  204. package/src/shell/lib/fayz-agent.ts +56 -0
  205. package/src/shell/lib/i18n.ts +71 -0
  206. package/src/shell/stores/chat.store.ts +118 -1
  207. package/src/shell/styles.css +30 -0
  208. package/src/shell/types/billing.ts +8 -0
  209. package/src/shell/types/plugins.ts +13 -50
  210. package/src/stores/createCrudStore.ts +9 -0
  211. package/dist/chunk-3M25GRRU.js.map +0 -1
  212. package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
  213. package/dist/chunk-5GYQK5IW.js +0 -21
  214. package/dist/chunk-5GYQK5IW.js.map +0 -1
  215. package/dist/chunk-IPWCHJPE.cjs.map +0 -1
  216. package/dist/chunk-KYID2MR6.cjs +0 -23
  217. package/dist/chunk-KYID2MR6.cjs.map +0 -1
  218. package/dist/chunk-XNWCGJYH.js.map +0 -1
  219. package/dist/shell/components/billing/BillingPage.d.ts +0 -7
  220. package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
  221. package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
  222. package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
  223. package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
  224. package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
  225. package/dist/shell/hooks/useBilling.d.ts +0 -14
  226. package/dist/shell/hooks/useBilling.d.ts.map +0 -1
  227. package/dist/shell/hooks/usePermission.d.ts +0 -16
  228. package/dist/shell/hooks/usePermission.d.ts.map +0 -1
  229. package/src/shell/components/billing/BillingPage.tsx +0 -56
  230. package/src/shell/components/billing/PaywallGate.tsx +0 -80
  231. package/src/shell/components/organization/PermissionGate.tsx +0 -16
  232. package/src/shell/hooks/useBilling.ts +0 -151
  233. package/src/shell/hooks/usePermission.ts +0 -101
@@ -13,28 +13,40 @@ import {
13
13
  DropdownItem,
14
14
  DropdownLabel,
15
15
  DropdownSeparator,
16
- Avatar,
17
- AvatarImage,
18
- AvatarFallback,
16
+ Skeleton,
19
17
  useLayoutStore,
18
+ Popover,
19
+ PopoverTrigger,
20
+ PopoverContent,
20
21
  } from '@fayz-ai/ui'
21
22
  import { CommandPalette, type CommandItem } from '../shell/components/layout/CommandPalette'
22
- import { useOrganizationStore } from '../org/store'
23
23
  import { useAuth } from '@fayz-ai/auth'
24
- import { AuthGate } from '@fayz-ai/plugin-auth'
24
+ import { AuthGate, type LoginAmbassador } from '@fayz-ai/plugin-auth'
25
25
  import { usePluginRuntime, resolvePluginComponent, useTranslation } from '@fayz-ai/core'
26
- import type { PermissionAction, PluginNavigationEntry, PluginRouteDefinition, PluginSettingsTab } from '@fayz-ai/core'
26
+ import type { PermissionAction, PluginNavigationEntry, PluginRouteDefinition, PluginSettingsTab, SystemPermission } from '@fayz-ai/core'
27
+
28
+ /** Route/nav RBAC requirement (mirror of core's PluginPermissionRequirement,
29
+ * which is not re-exported from the package index). */
30
+ type PermissionRequirement = { feature: string; action: PermissionAction }
27
31
  import { useAdminPath, navigateTo, matchRoute, routeScore } from './routing'
28
32
  import type { CustomPage } from './config'
29
33
  import type { AuthProvider } from '@fayz-ai/core'
30
34
  import { setEntityRouteMap } from '../lib/entity-routes'
31
35
  import { usePermissionOptional } from '../permissions/context'
32
- import { SettingsPage } from '../shell/components/settings/SettingsPage'
36
+ import { usePermissionsStore } from '../permissions'
37
+ import { useTenantOptional } from '../org/context'
38
+ import { WidgetSlot } from '../plugins/WidgetSlot'
39
+ import { useNotifications } from '../shell/hooks/useNotifications'
40
+ import { NotificationInbox } from '../shell/components/notifications/NotificationInbox'
41
+ import { SettingsPage, useCoreSettingsTabs, type SettingsTab } from '../shell/components/settings/SettingsPage'
33
42
  import { TeamTab } from '../shell/components/organization/TeamTab'
34
43
  import { PermissionProfilesTab } from '../shell/components/organization/PermissionProfilesTab'
35
44
  import { LocationsCrudPage } from '../shell/components/settings/LocationsCrudPage'
36
45
  import { ConnectedFieldRulesSettings } from '../shell/components/settings/ConnectedFieldRulesSettings'
37
- import { MapPin, Puzzle, ShieldCheck, SlidersHorizontal, Users } from 'lucide-react'
46
+ import { SubscriptionPage } from '../shell/components/billing/SubscriptionPage'
47
+ import { UpgradePrompt, UpgradeModal, SoftLimitBanner, useAccessOptional, getPlanEntitlements } from '../shell/components/billing'
48
+ import { useBillingStore, resolvePlanBadge } from '../billing'
49
+ import { ArrowLeft, Bell, CreditCard, Crown, LogOut, MapPin, Puzzle, Settings as SettingsIcon, ShieldAlert, ShieldCheck, SlidersHorizontal, Sparkles, UserCog, Users } from 'lucide-react'
38
50
  import * as LucideIcons from 'lucide-react'
39
51
 
40
52
  // ---------------------------------------------------------------------------
@@ -55,6 +67,8 @@ export interface AdminShellProps {
55
67
  loginDescription?: string
56
68
  loginLogo?: React.ReactNode
57
69
  loginLayout?: 'split' | 'centered'
70
+ loginAmbassadors?: LoginAmbassador[]
71
+ loginAmbassadorsLabel?: string
58
72
  showOAuth?: boolean
59
73
  oauthProviders?: Exclude<AuthProvider, 'email'>[]
60
74
  showSettings?: boolean
@@ -82,26 +96,61 @@ export interface AdminShellProps {
82
96
 
83
97
  // ---------------------------------------------------------------------------
84
98
  // WorkspaceSwitcher — sub-account / workspace selector at the sidebar top.
85
- // Reads the native org store (the one AdminProviders populates).
99
+ // Drives the native tenant context (useTenant). switchOrg re-hydrates the org
100
+ // (getOrg + members + permission profiles), so picking a workspace actually
101
+ // reloads the members list, role dropdowns and permission checks — the old
102
+ // setCurrentOrg(partial) path only swapped the name and left stores stale.
86
103
  // ---------------------------------------------------------------------------
87
104
 
88
- function WorkspaceSwitcher() {
89
- const currentOrg = useOrganizationStore((s) => s.currentOrg)
90
- const userOrgs = useOrganizationStore((s) => s.userOrgs)
91
- const setCurrentOrg = useOrganizationStore((s) => s.setCurrentOrg)
92
- if (!currentOrg) return null
105
+ function WorkspaceSwitcher({ variant = 'sidebar' }: { variant?: 'sidebar' | 'topbar' }) {
106
+ const tenant = useTenantOptional()
107
+ const currentOrg = tenant?.org ?? null
108
+ const t = useTranslation()
109
+ if (!tenant) return null
110
+ // Org still hydrating → hold the slot with a trigger-shaped skeleton instead
111
+ // of collapsing to nothing and popping in later (layout shift in the bar).
112
+ if (!currentOrg) {
113
+ if (!tenant.loading) return null
114
+ return (
115
+ <div
116
+ className={
117
+ variant === 'topbar'
118
+ ? 'flex w-[180px] items-center gap-2 rounded-md border border-sidebar-border/60 bg-sidebar-accent/30 px-2 py-1.5'
119
+ : 'flex w-full items-center gap-2 rounded-md border border-sidebar-border/60 bg-sidebar-accent/30 px-2 py-1.5'
120
+ }
121
+ >
122
+ <Skeleton className="h-6 w-6 shrink-0 rounded bg-sidebar-accent" />
123
+ <Skeleton className="h-4 flex-1 rounded bg-sidebar-accent" />
124
+ </div>
125
+ )
126
+ }
93
127
 
128
+ const { userOrgs, switchOrg, loading } = tenant
94
129
  const Building = LucideIcons.Building2
95
130
  const Chevron = LucideIcons.ChevronsUpDown
96
131
  const Check = LucideIcons.Check
97
132
  const options = userOrgs.length
98
133
  ? userOrgs
99
134
  : [{ orgId: currentOrg.id, orgName: currentOrg.name, orgSlug: currentOrg.slug }]
135
+ const isSingle = options.length <= 1
136
+ const tr = (key: string, fallback: string) => {
137
+ const v = t(key)
138
+ return !v || v === key ? fallback : v
139
+ }
100
140
 
101
141
  return (
102
142
  <Dropdown>
103
143
  <DropdownTrigger asChild>
104
- <button className="flex w-full items-center gap-2 rounded-md border border-sidebar-border/60 bg-sidebar-accent/30 px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent">
144
+ <button
145
+ className={
146
+ variant === 'topbar'
147
+ // Inline beside the topbar logo (same bg-sidebar surface): compact,
148
+ // capped width instead of filling the row.
149
+ ? 'flex max-w-[220px] items-center gap-2 rounded-md border border-sidebar-border/60 bg-sidebar-accent/30 px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent disabled:opacity-70'
150
+ : 'flex w-full items-center gap-2 rounded-md border border-sidebar-border/60 bg-sidebar-accent/30 px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent disabled:opacity-70'
151
+ }
152
+ disabled={loading}
153
+ >
105
154
  <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-sidebar-accent text-sidebar-accent-foreground">
106
155
  <Building className="h-3.5 w-3.5" />
107
156
  </span>
@@ -109,19 +158,26 @@ function WorkspaceSwitcher() {
109
158
  <Chevron className="h-3.5 w-3.5 shrink-0 text-sidebar-muted" />
110
159
  </button>
111
160
  </DropdownTrigger>
112
- <DropdownContent align="start" className="w-56 bg-sidebar text-sidebar-foreground border-sidebar-border">
113
- <DropdownLabel className="text-sidebar-muted">Workspaces</DropdownLabel>
114
- <DropdownSeparator className="bg-sidebar-border" />
161
+ {/* Plain popover-toned dropdown (light in light mode) — only the TRIGGER
162
+ matches the dark sidebar/topbar surface it sits on. */}
163
+ <DropdownContent align="start" className="w-56">
164
+ <DropdownLabel>{tr('organization.workspaces', 'Workspaces')}</DropdownLabel>
165
+ <DropdownSeparator />
115
166
  {options.map((m) => (
116
167
  <DropdownItem
117
168
  key={m.orgId}
118
- onClick={() => setCurrentOrg({ id: m.orgId, name: m.orgName, slug: m.orgSlug, createdAt: '', updatedAt: '' })}
119
- className="flex items-center gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground"
169
+ onClick={() => { if (m.orgId !== currentOrg.id) void switchOrg(m.orgId) }}
170
+ className="flex items-center gap-2"
120
171
  >
121
172
  <span className="min-w-0 flex-1 truncate">{m.orgName}</span>
122
173
  {currentOrg.id === m.orgId && <Check className="h-4 w-4 shrink-0" />}
123
174
  </DropdownItem>
124
175
  ))}
176
+ {isSingle && (
177
+ <p className="px-2 py-1.5 text-xs text-muted-foreground">
178
+ {tr('organization.onlyWorkspace', 'This is your only workspace.')}
179
+ </p>
180
+ )}
125
181
  </DropdownContent>
126
182
  </Dropdown>
127
183
  )
@@ -131,49 +187,157 @@ function WorkspaceSwitcher() {
131
187
  // AdminUserMenu — user dropdown rendered in the sidebar bottom row.
132
188
  // ---------------------------------------------------------------------------
133
189
 
190
+ /** Small plan pill shown next to the user across both menus. */
191
+ function PlanBadge({ badge }: { badge: { label: string; paid: boolean } }) {
192
+ return (
193
+ <span
194
+ className={
195
+ badge.paid
196
+ ? 'inline-flex items-center gap-1 rounded-full border border-amber-400/40 bg-amber-400/15 px-2 py-0.5 text-[10px] font-semibold text-amber-600 dark:text-amber-300'
197
+ : 'inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground'
198
+ }
199
+ >
200
+ {badge.paid ? <Crown className="h-3 w-3" /> : <Sparkles className="h-3 w-3" />}
201
+ {badge.label}
202
+ </span>
203
+ )
204
+ }
205
+
134
206
  function AdminUserMenu({
135
207
  user,
208
+ planBadge,
136
209
  onProfile,
137
210
  onSettings,
211
+ onBilling,
138
212
  onSignOut,
213
+ profileLabel,
214
+ settingsLabel,
215
+ subscriptionLabel,
216
+ signOutLabel,
139
217
  }: {
140
218
  user?: { fullName?: string; email: string; avatarUrl?: string }
219
+ planBadge?: { label: string; paid: boolean } | null
141
220
  onProfile?: () => void
142
221
  onSettings?: () => void
222
+ onBilling?: () => void
143
223
  onSignOut?: () => void
224
+ profileLabel: string
225
+ settingsLabel: string
226
+ subscriptionLabel: string
227
+ signOutLabel: string
144
228
  }) {
145
229
  if (!user) return null
146
- const name = user.fullName ?? user.email
147
- const initials = name.replace(/[^A-Za-z0-9]/g, ' ').trim().split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase() || '?'
230
+ const name = user.fullName?.trim() || user.email
231
+ const initials =
232
+ name.replace(/[^A-Za-z0-9]/g, ' ').trim().split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase() ||
233
+ (user.email[0]?.toUpperCase() ?? '?')
148
234
  return (
149
235
  <Dropdown>
150
236
  <DropdownTrigger asChild>
151
- <button className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1 text-left transition-colors hover:bg-sidebar-accent/50">
152
- <Avatar className="h-7 w-7 shrink-0">
153
- {user.avatarUrl && <AvatarImage src={user.avatarUrl} alt={name} />}
154
- <AvatarFallback>{initials}</AvatarFallback>
155
- </Avatar>
156
- <span className="min-w-0 truncate text-xs font-medium text-sidebar-foreground">{name}</span>
237
+ <button className="flex w-full min-w-0 items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent">
238
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-sidebar-accent text-xs font-semibold text-sidebar-accent-foreground">
239
+ {user.avatarUrl ? (
240
+ <img src={user.avatarUrl} alt="" className="h-full w-full object-cover" />
241
+ ) : (
242
+ initials
243
+ )}
244
+ </span>
245
+ <span className="min-w-0 flex-1 truncate text-xs font-medium text-sidebar-foreground">{name}</span>
157
246
  </button>
158
247
  </DropdownTrigger>
159
- <DropdownContent align="start" className="w-56 bg-sidebar text-sidebar-foreground border-sidebar-border">
248
+ <DropdownContent align="start" className="w-60 bg-sidebar text-sidebar-foreground border-sidebar-border">
160
249
  <DropdownLabel className="truncate text-sidebar-muted">{user.email}</DropdownLabel>
161
250
  <DropdownSeparator className="bg-sidebar-border" />
162
251
  {onProfile && (
163
- <DropdownItem onClick={onProfile} className="focus:bg-sidebar-accent focus:text-sidebar-accent-foreground">Profile</DropdownItem>
252
+ <DropdownItem onClick={onProfile} className="flex items-center gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground">
253
+ <UserCog className="h-4 w-4 shrink-0" />
254
+ <span className="flex-1">{profileLabel}</span>
255
+ </DropdownItem>
164
256
  )}
165
257
  {onSettings && (
166
- <DropdownItem onClick={onSettings} className="focus:bg-sidebar-accent focus:text-sidebar-accent-foreground">Settings</DropdownItem>
258
+ <DropdownItem onClick={onSettings} className="flex items-center gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground">
259
+ <SettingsIcon className="h-4 w-4 shrink-0" />
260
+ <span className="flex-1">{settingsLabel}</span>
261
+ </DropdownItem>
262
+ )}
263
+ {onBilling && (
264
+ <DropdownItem onClick={onBilling} className="flex items-center gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground">
265
+ <CreditCard className="h-4 w-4 shrink-0" />
266
+ <span className="flex-1">{subscriptionLabel}</span>
267
+ {planBadge && <PlanBadge badge={planBadge} />}
268
+ </DropdownItem>
167
269
  )}
168
270
  <DropdownSeparator className="bg-sidebar-border" />
169
271
  {onSignOut && (
170
- <DropdownItem onClick={onSignOut} className="focus:bg-sidebar-accent focus:text-sidebar-accent-foreground">Sign out</DropdownItem>
272
+ <DropdownItem onClick={onSignOut} className="flex items-center gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground">
273
+ <LogOut className="h-4 w-4 shrink-0" />
274
+ <span className="flex-1">{signOutLabel}</span>
275
+ </DropdownItem>
171
276
  )}
172
277
  </DropdownContent>
173
278
  </Dropdown>
174
279
  )
175
280
  }
176
281
 
282
+ // ---------------------------------------------------------------------------
283
+ // AccessDenied — rendered in place of a page/plugin route whose declared
284
+ // permission the current user does not hold (RBAC route guard).
285
+ // ---------------------------------------------------------------------------
286
+
287
+ function AccessDenied({ tr }: { tr: (key: string, fallback: string) => string }) {
288
+ return (
289
+ <div className="flex h-full flex-col items-center justify-center p-12 text-center">
290
+ <ShieldAlert className="mb-3 h-10 w-10 text-muted-foreground" />
291
+ <p className="text-lg font-semibold text-foreground">
292
+ {tr('shell.accessDenied.title', 'Access restricted')}
293
+ </p>
294
+ <p className="mt-1 max-w-sm text-sm text-muted-foreground">
295
+ {tr('shell.accessDenied.message', "You don't have permission to view this page.")}
296
+ </p>
297
+ </div>
298
+ )
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // AdminNotifications — the sidebar notification bell. Fetches once on mount
303
+ // (degrades to an empty inbox if the notifications table/store is unavailable)
304
+ // and opens a functional inbox popover with mark-read / mark-all-read wired.
305
+ // ---------------------------------------------------------------------------
306
+
307
+ function AdminNotifications() {
308
+ const { notifications, unreadCount, fetchNotifications, markAsRead, markAllAsRead } = useNotifications()
309
+ const [open, setOpen] = React.useState(false)
310
+ const fetchedRef = React.useRef(false)
311
+ React.useEffect(() => {
312
+ if (fetchedRef.current) return
313
+ fetchedRef.current = true
314
+ void fetchNotifications()
315
+ }, [fetchNotifications])
316
+
317
+ return (
318
+ <Popover open={open} onOpenChange={setOpen}>
319
+ <PopoverTrigger asChild>
320
+ <button
321
+ className="relative inline-flex shrink-0 items-center justify-center rounded-md p-2 text-sidebar-muted transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground"
322
+ aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
323
+ >
324
+ <Bell className="h-4 w-4" />
325
+ {unreadCount > 0 && (
326
+ <span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-destructive" />
327
+ )}
328
+ </button>
329
+ </PopoverTrigger>
330
+ <PopoverContent align="end" sideOffset={8} className="w-80 p-0">
331
+ <NotificationInbox
332
+ notifications={notifications}
333
+ onMarkRead={markAsRead}
334
+ onMarkAllRead={markAllAsRead}
335
+ />
336
+ </PopoverContent>
337
+ </Popover>
338
+ )
339
+ }
340
+
177
341
  function navEntryToItem(entry: PluginNavigationEntry): NavigationItem {
178
342
  return {
179
343
  id: entry.id ?? `${entry.route}`,
@@ -182,16 +346,29 @@ function navEntryToItem(entry: PluginNavigationEntry): NavigationItem {
182
346
  route: entry.route,
183
347
  section: entry.section,
184
348
  badge: entry.badge,
349
+ permission: entry.permission as NavigationItem['permission'],
185
350
  }
186
351
  }
187
352
 
188
- type OrderedNavigationItem = NavigationItem & { position: number }
353
+ type OrderedNavigationItem = NavigationItem & { position: number; indexChild?: boolean }
354
+
355
+ /**
356
+ * Children list a parent starts with once it adopts its first sub-path child:
357
+ * normally a self-link so the parent's own route stays reachable, but empty
358
+ * when the entry opted out via `indexChild: false`.
359
+ */
360
+ function seedChildren(parent: OrderedNavigationItem): OrderedNavigationItem[] {
361
+ if (parent.indexChild === false) return []
362
+ return [{ ...parent, id: `${parent.id}:index`, children: undefined, position: -1 }]
363
+ }
189
364
 
190
365
  interface RouteEntry {
191
366
  path: string
192
367
  Component: React.ComponentType<Record<string, unknown>>
193
368
  /** Render edge-to-edge with no page padding wrapper (chat, kanban, canvas). */
194
369
  fullBleed?: boolean
370
+ /** RBAC guard — when set and unsatisfied the route renders <AccessDenied>. */
371
+ permission?: PermissionRequirement
195
372
  }
196
373
 
197
374
  function registerEntityRoute(
@@ -263,8 +440,9 @@ function collectPageRoutes(
263
440
  ;(page.component as any).__crudBasePath = page.path
264
441
  registerEntityRoute(page.component, page.path, entityRouteMap)
265
442
  }
266
- out.push({ path: page.path, Component: page.component as React.ComponentType<Record<string, unknown>> })
267
- out.push({ path: `${page.path}/*`, Component: page.component as React.ComponentType<Record<string, unknown>> })
443
+ const Component = page.component as React.ComponentType<Record<string, unknown>>
444
+ out.push({ path: page.path, Component, permission: page.permission })
445
+ out.push({ path: `${page.path}/*`, Component, permission: page.permission })
268
446
  }
269
447
  if (page.children?.length) collectPageRoutes(page.children, out, entityRouteMap)
270
448
  }
@@ -274,18 +452,43 @@ function collectPageRoutes(
274
452
  function buildSettingsTabs(
275
453
  pluginTabs: PluginSettingsTab[],
276
454
  can: (feature: string, action?: PermissionAction) => boolean,
455
+ hasSystem: (perm: SystemPermission) => boolean,
277
456
  t: (key: string, params?: Record<string, string | number>) => string,
278
457
  showOrgSettings: boolean,
279
- ): { id: string; label: string; icon?: React.ReactNode; component: React.ReactNode }[] {
280
- const settingsTabs: { id: string; label: string; icon?: React.ReactNode; component: React.ReactNode }[] = []
458
+ hasBilling: boolean,
459
+ ): SettingsTab[] {
460
+ const settingsTabs: SettingsTab[] = []
461
+
462
+ // Subscription — present whenever billing is configured, gated (like the other
463
+ // org tabs) on the system `manage_billing` permission. Route: /settings/subscription.
464
+ if (hasBilling && hasSystem('manage_billing')) {
465
+ const subLabel = t('settings.subscription')
466
+ settingsTabs.push({
467
+ id: 'subscription',
468
+ label: subLabel === 'settings.subscription' ? 'Subscription' : subLabel,
469
+ // CreditCard everywhere the LINK appears (menus + settings nav) — Crown
470
+ // is reserved for the paid-plan badge. 'Crown' also isn't in the
471
+ // sidebar ICON_MAP, which made the settings rail fall back to Home.
472
+ icon: <CreditCard className="h-4 w-4" />,
473
+ iconName: 'CreditCard',
474
+ component: <SubscriptionPage />,
475
+ })
476
+ }
281
477
 
282
478
  if (showOrgSettings) {
283
- settingsTabs.push(
284
- { id: 'team', label: t('settings.team'), icon: <Users className="h-4 w-4" />, component: <TeamTab /> },
285
- { id: 'permissions', label: t('settings.permissions'), icon: <ShieldCheck className="h-4 w-4" />, component: <PermissionProfilesTab /> },
286
- { id: 'locations', label: t('settings.locations'), icon: <MapPin className="h-4 w-4" />, component: <LocationsCrudPage /> },
287
- { id: 'field-rules', label: t('settings.fieldRules'), icon: <SlidersHorizontal className="h-4 w-4" />, component: <ConnectedFieldRulesSettings /> },
288
- )
479
+ // Org-management tabs are gated on the profile's SYSTEM permissions (the
480
+ // grants-based can() has no team/permissions/locations feature). Owner and
481
+ // no-RBAC profiles pass hasSystem() through, so the owner is never blocked.
482
+ if (hasSystem('manage_team'))
483
+ settingsTabs.push({ id: 'team', label: t('settings.team'), icon: <Users className="h-4 w-4" />, iconName: 'Users', component: <TeamTab /> })
484
+ if (hasSystem('manage_permissions'))
485
+ settingsTabs.push({ id: 'permissions', label: t('settings.permissions'), icon: <ShieldCheck className="h-4 w-4" />, iconName: 'ShieldCheck', component: <PermissionProfilesTab /> })
486
+ if (hasSystem('manage_settings')) {
487
+ settingsTabs.push(
488
+ { id: 'locations', label: t('settings.locations'), icon: <MapPin className="h-4 w-4" />, iconName: 'MapPin', component: <LocationsCrudPage /> },
489
+ { id: 'field-rules', label: t('settings.fieldRules'), icon: <SlidersHorizontal className="h-4 w-4" />, iconName: 'SlidersHorizontal', component: <ConnectedFieldRulesSettings /> },
490
+ )
491
+ }
289
492
  }
290
493
 
291
494
  for (const tab of pluginTabs) {
@@ -299,10 +502,11 @@ function buildSettingsTabs(
299
502
  id: tab.id,
300
503
  label: translated === labelKey ? tab.label : translated,
301
504
  icon: React.createElement(IconComp as React.ComponentType<{ className?: string }>, { className: 'h-4 w-4' }),
505
+ iconName: tab.icon ?? 'Puzzle',
302
506
  component: React.createElement(tab.component),
303
507
  // Flag plugin-contributed tabs so SettingsPage draws the core/plugins divider.
304
508
  isPlugin: true,
305
- } as any)
509
+ })
306
510
  }
307
511
 
308
512
  return settingsTabs
@@ -312,6 +516,22 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
312
516
  const runtime = usePluginRuntime()
313
517
  const t = useTranslation()
314
518
  const can = usePermissionOptional()
519
+ // Composed access (role × plan). Nav + route guard use this to distinguish a
520
+ // role denial (hide / AccessDenied) from a plan denial (Crown badge / UpgradePrompt).
521
+ const access = useAccessOptional()
522
+ const currentProfile = usePermissionsStore((s) => s.currentProfile)
523
+ // Org-management (system) permission check. A null profile (owner / no RBAC
524
+ // loaded) passes through so the owner is never blocked — the grants-based
525
+ // owner bypass lives in permissions/context.tsx and is not duplicated here.
526
+ const hasSystem = React.useCallback(
527
+ (perm: SystemPermission) => {
528
+ if (!currentProfile) return true
529
+ // Owner is never blocked (mirrors the owner bypass in permissions/context).
530
+ if (currentProfile.id === 'owner' || currentProfile.name?.toLowerCase() === 'owner') return true
531
+ return currentProfile.systemPermissions?.includes(perm) ?? false
532
+ },
533
+ [currentProfile],
534
+ )
315
535
  const tr = (key: string, fallback: string) => {
316
536
  const v = t(key)
317
537
  return !v || v === key ? fallback : v
@@ -319,12 +539,47 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
319
539
  const path = useAdminPath()
320
540
  const { user, signOut } = useAuth()
321
541
 
542
+ // Billing surface — plans are seeded into the store from `config.billing` (see
543
+ // BillingInitializer). A non-empty plan list is the signal that billing is
544
+ // configured: it drives the Subscription settings tab, the user-menu item, and
545
+ // the current-plan badge shown next to the user.
546
+ const billingPlans = useBillingStore((s) => s.plans)
547
+ const hasBilling = billingPlans.length > 0
548
+ const orgPlan = useTenantOptional()?.org?.plan
549
+ const planBadge = React.useMemo(
550
+ () => (hasBilling ? resolvePlanBadge(orgPlan, billingPlans) : null),
551
+ [hasBilling, orgPlan, billingPlans],
552
+ )
553
+ // Soft-limit monitoring — every limit key the active plan declares. The banner
554
+ // only surfaces a key whose usage EXCEEDS its cap (used > max), the Notion-style
555
+ // overshoot path (e.g. public bookings pushing clients past the free cap).
556
+ const softLimitKeys = React.useMemo(() => {
557
+ const plan = billingPlans.find((p) => p.id === (orgPlan || 'free'))
558
+ return Object.keys(getPlanEntitlements(plan)?.limits ?? {})
559
+ }, [billingPlans, orgPlan])
560
+
322
561
  // Navigation: plugin entries + custom pages (deduped, plugin order preserved).
323
562
  const navigation = React.useMemo<NavigationItem[]>(() => {
324
- const items: OrderedNavigationItem[] = runtime.navigation.map((entry) => ({
325
- ...navEntryToItem(entry),
326
- position: entry.position,
327
- }))
563
+ // Plugin entries whose route is a sub-path of another plugin entry nest as
564
+ // its children (same rule pages get below) — e.g. the courses modules
565
+ // (/courses/members, /courses/sales…) group under /courses instead of
566
+ // scattering across the rail. Sort by position first so parents land
567
+ // before their children.
568
+ const flat = runtime.navigation
569
+ .map((entry) => ({ ...navEntryToItem(entry), position: entry.position, indexChild: entry.indexChild }))
570
+ .sort(compareNavigation)
571
+ const items: OrderedNavigationItem[] = []
572
+ for (const item of flat) {
573
+ const parent = findModuleParent(item, items)
574
+ if (parent) {
575
+ const children = parent.children ?? seedChildren(parent)
576
+ if (!children.some((child) => child.route === item.route)) {
577
+ parent.children = [...children, item].sort(compareNavigation)
578
+ }
579
+ continue
580
+ }
581
+ items.push(item)
582
+ }
328
583
  for (const [index, page] of pages.entries()) {
329
584
  // `nav: false` routes the page but keeps it out of the sidebar/topbar nav
330
585
  // (mobile-only pages reached via bottomNav/avatar). Routes are still built
@@ -335,12 +590,7 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
335
590
 
336
591
  const parent = findModuleParent(item, items)
337
592
  if (parent) {
338
- const children = parent.children ?? [{
339
- ...parent,
340
- id: `${parent.id}:index`,
341
- children: undefined,
342
- position: -1,
343
- }]
593
+ const children = parent.children ?? seedChildren(parent)
344
594
  if (!children.some((child) => child.route === item.route)) {
345
595
  parent.children = [...children, item].sort(compareNavigation)
346
596
  }
@@ -349,8 +599,30 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
349
599
 
350
600
  items.push(item)
351
601
  }
352
- return items.sort(compareNavigation)
353
- }, [runtime.navigation, pages])
602
+ // Access-aware nav gating. Role denial hides the entry (owner/no-profile pass
603
+ // through). Plan denial KEEPS it (freemium discovery) with a Crown badge that
604
+ // leads to the route's UpgradePrompt. Children follow the same rule.
605
+ const decide = (perm?: NavigationItem['permission']): { keep: boolean; premium: boolean } => {
606
+ if (!perm) return { keep: true, premium: false }
607
+ const d = access.can(perm.feature, perm.action)
608
+ if (!d.allowed && d.reason === 'role') return { keep: false, premium: false }
609
+ return { keep: true, premium: !d.allowed && d.reason === 'plan' }
610
+ }
611
+ return items
612
+ .map((item): OrderedNavigationItem | null => {
613
+ const dec = decide(item.permission)
614
+ if (!dec.keep) return null
615
+ const children = item.children
616
+ ?.map((child): NavigationItem | null => {
617
+ const cd = decide(child.permission)
618
+ return cd.keep ? { ...child, premium: cd.premium } : null
619
+ })
620
+ .filter((c): c is NavigationItem => c !== null)
621
+ return { ...item, premium: dec.premium, children }
622
+ })
623
+ .filter((item): item is OrderedNavigationItem => item !== null)
624
+ .sort(compareNavigation)
625
+ }, [runtime.navigation, pages, access])
354
626
 
355
627
  // Command palette (⌘K + the topbar Search button). The palette owns the ⌘K
356
628
  // key listener, so it must be MOUNTED for the shortcut to work — the topbar
@@ -376,8 +648,15 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
376
648
  for (const r of runtime.routes as PluginRouteDefinition[]) {
377
649
  const Component = resolvePluginComponent(r) as React.ComponentType<Record<string, unknown>> | undefined
378
650
  if (Component) {
379
- list.push({ path: r.path, Component, fullBleed: r.fullBleed })
380
- list.push({ path: `${r.path}/*`, Component, fullBleed: r.fullBleed })
651
+ // Same treatment custom pages get in collectPageRoutes: a generated CRUD
652
+ // page needs to know the path it was mounted at, otherwise its "new" and
653
+ // "edit" links resolve against '/' (e.g. /new instead of /shop/products/new).
654
+ if ((Component as any).__isCrudPage) {
655
+ ;(Component as any).__crudBasePath = r.path
656
+ registerEntityRoute(Component, r.path, entityRouteMap)
657
+ }
658
+ list.push({ path: r.path, Component, fullBleed: r.fullBleed, permission: r.permission })
659
+ list.push({ path: `${r.path}/*`, Component, fullBleed: r.fullBleed, permission: r.permission })
381
660
  }
382
661
  }
383
662
  if (showSettings) list.push({ path: '/settings/*', Component: SettingsPage as React.ComponentType<Record<string, unknown>> })
@@ -405,8 +684,53 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
405
684
  const ActiveComponent = match?.route.Component
406
685
  const activeParams = match?.params ?? {}
407
686
  const settingsTabs = React.useMemo(
408
- () => buildSettingsTabs(runtime.settingsTabs as PluginSettingsTab[], can, t, showOrgSettings),
409
- [runtime.settingsTabs, can, t, showOrgSettings],
687
+ () => buildSettingsTabs(runtime.settingsTabs as PluginSettingsTab[], can, hasSystem, t, showOrgSettings, hasBilling),
688
+ [runtime.settingsTabs, can, hasSystem, t, showOrgSettings, hasBilling],
689
+ )
690
+
691
+ // Resolved chrome routes: profile lands on a dedicated /perfil|/profile page
692
+ // if one is registered, else the Settings → Profile tab; billing only when a
693
+ // matching route actually exists (no dead nav item otherwise).
694
+ const profileRoute = React.useMemo(() => {
695
+ const explicit = routes.find((r) => r.path === '/perfil' || r.path === '/profile')
696
+ if (explicit) return explicit.path
697
+ return showSettings ? '/settings/profile' : null
698
+ }, [routes, showSettings])
699
+ const onProfile = profileRoute ? () => navigateTo(profileRoute) : undefined
700
+ // Subscription lives at the always-present /settings/subscription tab whenever
701
+ // billing is configured — no need to hunt for a standalone billing route.
702
+ const onBilling = hasBilling && showSettings ? () => navigateTo('/settings/subscription') : undefined
703
+
704
+ // Contextual /settings sidebar (desktop sidebar layout only): the app rail is
705
+ // replaced by "← Back to home" + the settings links, so settings isn't a menu
706
+ // inside a menu. `coreSettingsTabs` mirrors what SettingsPage renders.
707
+ const coreSettingsTabs = useCoreSettingsTabs({ showCompany: showBranding, showBranding })
708
+ const allSettingsTabs = React.useMemo(
709
+ () => [...coreSettingsTabs, ...settingsTabs],
710
+ [coreSettingsTabs, settingsTabs],
711
+ )
712
+ const inSettings = showSettings && layout === 'sidebar' && (path === '/settings' || path.startsWith('/settings/'))
713
+ const homeRoute = navigation[0]?.route ?? '/'
714
+ // With the contextual sidebar the shell owns settings navigation, so it also
715
+ // owns the active tab: parse it from the URL and drive SettingsPage as a
716
+ // controlled component (bare /settings → first tab). This is what makes
717
+ // clicking another settings link actually swap the content.
718
+ const activeSettingsTab = inSettings
719
+ ? (path === '/settings'
720
+ ? allSettingsTabs[0]?.id
721
+ : path.slice('/settings/'.length).split('/')[0])
722
+ : undefined
723
+ const settingsNavigation = React.useMemo<NavigationItem[]>(
724
+ () => allSettingsTabs.map((tab) => ({
725
+ id: `settings:${tab.id}`,
726
+ label: tab.label,
727
+ icon: tab.iconName ?? 'Settings',
728
+ route: `/settings/${tab.id}`,
729
+ // Plugin tabs land in the second group so the sidebar can head them
730
+ // "Plugins", matching the divider the in-page nav used to draw.
731
+ section: tab.isPlugin ? 'secondary' : 'main',
732
+ })),
733
+ [allSettingsTabs],
410
734
  )
411
735
 
412
736
  const shellUser = user
@@ -430,35 +754,85 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
430
754
  return hit?.label ?? candidates.find((n) => n.route === '/')?.label ?? ''
431
755
  }, [navigation, path, tr])
432
756
 
757
+ // Access-aware route guard. A role denial renders <AccessDenied>; a plan denial
758
+ // renders a full-page <UpgradePrompt> for the route's feature ("Premium feature"
759
+ // + upgrade cards) instead of pretending the page doesn't exist.
760
+ const routeDecision = match?.route.permission
761
+ ? access.can(match.route.permission.feature, match.route.permission.action)
762
+ : { allowed: true as const }
433
763
  const activeContent = ActiveComponent ? (
434
- match.route.path === '/settings' || match.route.path === '/settings/*'
435
- ? <SettingsPage extraTabs={settingsTabs} showCompany={showBranding} showBranding={showBranding} />
436
- : <ActiveComponent {...activeParams} />
764
+ !routeDecision.allowed ? (
765
+ routeDecision.reason === 'plan' ? (
766
+ <UpgradePrompt feature={match!.route.permission!.feature} />
767
+ ) : (
768
+ <AccessDenied tr={tr} />
769
+ )
770
+ ) : match.route.path === '/settings' || match.route.path === '/settings/*' ? (
771
+ <SettingsPage tabs={allSettingsTabs} showCompany={showBranding} showBranding={showBranding} hideNav={inSettings} activeTabId={activeSettingsTab} />
772
+ ) : (
773
+ <ActiveComponent {...activeParams} />
774
+ )
437
775
  ) : null
438
776
 
777
+ const settingsBackLink = (
778
+ <button
779
+ onClick={() => navigateTo(homeRoute)}
780
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] font-medium text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground"
781
+ >
782
+ <ArrowLeft className="h-4 w-4 shrink-0" />
783
+ <span className="truncate">{tr('settings.backToHome', 'Back to home')}</span>
784
+ </button>
785
+ )
786
+
439
787
  return (
440
788
  <ModuleLayoutProvider variant={moduleVariant}>
441
789
  <AppShell
442
790
  variant={layout}
443
791
  contentFrame={contentFrame}
444
- navigation={navigation}
792
+ navigation={inSettings ? settingsNavigation : navigation}
793
+ sidebarSectionLabels={inSettings ? {
794
+ main: tr('settings.navLinks', 'Settings links'),
795
+ secondary: tr('common.plugins', 'Plugins'),
796
+ } : undefined}
797
+ // Entering settings pushes forward (new rail in from the right); going
798
+ // back to the app rail reverses it.
799
+ sidebarNavKey={inSettings ? 'settings' : 'app'}
800
+ sidebarNavDirection={inSettings ? 'forward' : 'back'}
445
801
  logo={logo ?? <span className="text-lg font-bold">{appName}</span>}
446
802
  user={shellUser}
447
803
  pageTitle={activePageTitle}
448
- currentPath={path}
804
+ // Bare /settings renders the first tab, so highlight it in the rail too.
805
+ currentPath={inSettings && path === '/settings' ? `/settings/${allSettingsTabs[0]?.id ?? ''}` : path}
449
806
  bottomNav={bottomNav}
450
807
  onBottomNavAction={onBottomNavAction}
451
808
  mobileHeader={mobileHeader}
452
809
  onNavigate={(route) => navigateTo(route)}
453
810
  onSignOut={() => { void signOut() }}
454
- onProfile={() => navigateTo('/perfil')}
811
+ onProfile={onProfile}
812
+ onBilling={onBilling}
455
813
  onSettings={() => navigateTo('/settings')}
456
- sidebarTopContent={showOrgSwitcher ? <WorkspaceSwitcher /> : undefined}
814
+ userPlan={planBadge ? { label: planBadge.label, paid: planBadge.paid } : undefined}
815
+ billingLabel={tr('settings.subscription', 'Subscription')}
816
+ sidebarTopContent={inSettings ? settingsBackLink : showOrgSwitcher ? <WorkspaceSwitcher /> : undefined}
817
+ // Topbar layout has no sidebar top slot — the switcher rides beside the
818
+ // logo instead (and in the mobile drawer). Sidebar layout keeps using
819
+ // sidebarTopContent above, so this stays undefined there (no double render).
820
+ orgSwitcher={layout === 'topbar' && !inSettings && showOrgSwitcher ? <WorkspaceSwitcher variant="topbar" /> : undefined}
821
+ topbarStart={<WidgetSlot zone="shell.topbar.start" />}
822
+ topbarEnd={<WidgetSlot zone="shell.topbar.end" />}
823
+ notificationSlot={<AdminNotifications />}
457
824
  userMenuSlot={
458
825
  <AdminUserMenu
459
826
  user={shellUser}
827
+ planBadge={planBadge}
828
+ onProfile={onProfile}
460
829
  onSettings={() => navigateTo('/settings')}
830
+ onBilling={onBilling}
461
831
  onSignOut={() => { void signOut() }}
832
+ profileLabel={tr('settings.profile', 'Profile')}
833
+ settingsLabel={tr('settings.title', 'Settings')}
834
+ subscriptionLabel={tr('settings.subscription', 'Subscription')}
835
+ signOutLabel={tr('auth.signOut', 'Sign out')}
462
836
  />
463
837
  }
464
838
  >
@@ -469,7 +843,12 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
469
843
  >
470
844
  {match?.route.fullBleed
471
845
  ? <div className="h-full min-h-0 overflow-hidden">{activeContent}</div>
472
- : <div className="space-y-4 p-3 md:space-y-6 md:p-6">{activeContent}</div>}
846
+ : (
847
+ <div className="space-y-4 p-3 md:space-y-6 md:p-6">
848
+ {softLimitKeys.length > 0 && <SoftLimitBanner keys={softLimitKeys} />}
849
+ {activeContent}
850
+ </div>
851
+ )}
473
852
  </PageTransition>
474
853
  ) : (
475
854
  <div className="flex h-full flex-col items-center justify-center p-12 text-center">
@@ -492,6 +871,8 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
492
871
  open={commandPaletteOpen}
493
872
  onOpenChange={setCommandPaletteOpen}
494
873
  />
874
+ {/* Global upgrade dialog — opened imperatively by LimitGate / useLimitGuard. */}
875
+ <UpgradeModal />
495
876
  </ModuleLayoutProvider>
496
877
  )
497
878
  }
@@ -507,6 +888,8 @@ export function AdminShell(props: AdminShellProps) {
507
888
  layout={props.loginLayout}
508
889
  tagline={props.loginTagline}
509
890
  description={props.loginDescription}
891
+ loginAmbassadors={props.loginAmbassadors}
892
+ loginAmbassadorsLabel={props.loginAmbassadorsLabel}
510
893
  showOAuth={props.showOAuth}
511
894
  oauthProviders={props.oauthProviders}
512
895
  >