@fayz-ai/saas 0.8.0 → 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.
- package/dist/access/context.d.ts +47 -0
- package/dist/access/context.d.ts.map +1 -0
- package/dist/access/headless.d.ts +21 -0
- package/dist/access/headless.d.ts.map +1 -0
- package/dist/access/index.d.ts +12 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/limits-registry.d.ts +33 -0
- package/dist/access/limits-registry.d.ts.map +1 -0
- package/dist/access/module-nav.d.ts +15 -0
- package/dist/access/module-nav.d.ts.map +1 -0
- package/dist/access/resolver.d.ts +2 -0
- package/dist/access/resolver.d.ts.map +1 -0
- package/dist/access/types.d.ts +43 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access/upgrade-modal-store.d.ts +23 -0
- package/dist/access/upgrade-modal-store.d.ts.map +1 -0
- package/dist/app/AdminShell.d.ts +3 -0
- package/dist/app/AdminShell.d.ts.map +1 -1
- package/dist/app/admin-app.d.ts +3 -1
- package/dist/app/admin-app.d.ts.map +1 -1
- package/dist/app/config.d.ts +47 -2
- package/dist/app/config.d.ts.map +1 -1
- package/dist/app/core-entities.d.ts +25 -0
- package/dist/app/core-entities.d.ts.map +1 -0
- package/dist/app/derive-contract.d.ts +8 -0
- package/dist/app/derive-contract.d.ts.map +1 -0
- package/dist/app/scaffold.d.ts.map +1 -1
- package/dist/archetype-lookup.d.ts.map +1 -1
- package/dist/billing/index.cjs +6 -2
- package/dist/billing/index.d.ts +2 -0
- package/dist/billing/index.d.ts.map +1 -1
- package/dist/billing/index.js +1 -1
- package/dist/billing/plan-badge.d.ts +17 -0
- package/dist/billing/plan-badge.d.ts.map +1 -0
- package/dist/billing/store.d.ts +6 -1
- package/dist/billing/store.d.ts.map +1 -1
- package/dist/{chunk-3M25GRRU.js → chunk-I3DY47N5.js} +3 -4
- package/dist/chunk-I3DY47N5.js.map +1 -0
- package/dist/chunk-NNFGMUF2.cjs +39 -0
- package/dist/chunk-NNFGMUF2.cjs.map +1 -0
- package/dist/{chunk-4XTQ4V5L.cjs → chunk-SAZLW54Y.cjs} +16 -12
- package/dist/chunk-SAZLW54Y.cjs.map +1 -0
- package/dist/{chunk-IPWCHJPE.cjs → chunk-VKYZACG5.cjs} +5 -6
- package/dist/chunk-VKYZACG5.cjs.map +1 -0
- package/dist/chunk-VN5NTK26.js +36 -0
- package/dist/chunk-VN5NTK26.js.map +1 -0
- package/dist/{chunk-XNWCGJYH.js → chunk-VTEP2AQX.js} +15 -11
- package/dist/chunk-VTEP2AQX.js.map +1 -0
- package/dist/components/shared/ContactPicker.d.ts +92 -0
- package/dist/components/shared/ContactPicker.d.ts.map +1 -0
- package/dist/components/shared/PersonLink.d.ts +12 -1
- package/dist/components/shared/PersonLink.d.ts.map +1 -1
- package/dist/components/shared/create-person.d.ts +20 -0
- package/dist/components/shared/create-person.d.ts.map +1 -0
- package/dist/crud/CrudDetailPage.d.ts.map +1 -1
- package/dist/crud/CrudFormPage.d.ts.map +1 -1
- package/dist/crud/CrudListView.d.ts.map +1 -1
- package/dist/crud/CrudPage.d.ts.map +1 -1
- package/dist/crud/archetypes/AddressesTab.d.ts +21 -0
- package/dist/crud/archetypes/AddressesTab.d.ts.map +1 -0
- package/dist/crud/archetypes/PersonDetailTabs.d.ts +2 -1
- package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
- package/dist/crud/createCrudPage.d.ts.map +1 -1
- package/dist/crud/index.d.ts +1 -0
- package/dist/crud/index.d.ts.map +1 -1
- package/dist/index.cjs +4300 -722
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +17 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4080 -542
- package/dist/index.js.map +1 -1
- package/dist/org/index.cjs +11 -11
- package/dist/org/index.js +2 -2
- package/dist/org/store.d.ts.map +1 -1
- package/dist/permissions/context.d.ts +2 -0
- package/dist/permissions/context.d.ts.map +1 -1
- package/dist/permissions/index.cjs +7 -7
- package/dist/permissions/index.js +1 -1
- package/dist/shell/components/auth/ProtectedRoute.d.ts.map +1 -1
- package/dist/shell/components/billing/SoftLimitBanner.d.ts +5 -0
- package/dist/shell/components/billing/SoftLimitBanner.d.ts.map +1 -0
- package/dist/shell/components/billing/SubscriptionPage.d.ts +5 -0
- package/dist/shell/components/billing/SubscriptionPage.d.ts.map +1 -0
- package/dist/shell/components/billing/UpgradeModal.d.ts +3 -0
- package/dist/shell/components/billing/UpgradeModal.d.ts.map +1 -0
- package/dist/shell/components/billing/UpgradePrompt.d.ts +10 -0
- package/dist/shell/components/billing/UpgradePrompt.d.ts.map +1 -0
- package/dist/shell/components/billing/access-contract.d.ts +18 -0
- package/dist/shell/components/billing/access-contract.d.ts.map +1 -0
- package/dist/shell/components/billing/gates.d.ts +14 -0
- package/dist/shell/components/billing/gates.d.ts.map +1 -0
- package/dist/shell/components/billing/index.d.ts +9 -2
- package/dist/shell/components/billing/index.d.ts.map +1 -1
- package/dist/shell/components/chat/ChatFab.d.ts +3 -1
- package/dist/shell/components/chat/ChatFab.d.ts.map +1 -1
- package/dist/shell/components/chat/ChatPanel.d.ts +3 -1
- package/dist/shell/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/shell/components/chat/ConfirmActionCard.d.ts +13 -0
- package/dist/shell/components/chat/ConfirmActionCard.d.ts.map +1 -0
- package/dist/shell/components/chat/markdown.d.ts +5 -0
- package/dist/shell/components/chat/markdown.d.ts.map +1 -0
- package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
- package/dist/shell/components/organization/TenantOnboarding.d.ts.map +1 -1
- package/dist/shell/components/organization/index.d.ts +0 -1
- package/dist/shell/components/organization/index.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedCompanySettings.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedFieldRulesSettings.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedSecuritySettings.d.ts.map +1 -1
- package/dist/shell/components/settings/LocationsCrudPage.d.ts +1 -1
- package/dist/shell/components/settings/LocationsCrudPage.d.ts.map +1 -1
- package/dist/shell/components/settings/SettingsPage.d.ts +23 -2
- package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
- package/dist/shell/hooks/index.d.ts +3 -2
- package/dist/shell/hooks/index.d.ts.map +1 -1
- package/dist/shell/hooks/useAITools.d.ts +2 -0
- package/dist/shell/hooks/useAITools.d.ts.map +1 -1
- package/dist/shell/hooks/useChat.d.ts +22 -0
- package/dist/shell/hooks/useChat.d.ts.map +1 -1
- package/dist/shell/hooks/useSystemPermission.d.ts +3 -0
- package/dist/shell/hooks/useSystemPermission.d.ts.map +1 -0
- package/dist/shell/hooks/useTenantPluginSettings.d.ts +12 -0
- package/dist/shell/hooks/useTenantPluginSettings.d.ts.map +1 -0
- package/dist/shell/lib/ai-tool-handlers.d.ts +119 -0
- package/dist/shell/lib/ai-tool-handlers.d.ts.map +1 -0
- package/dist/shell/lib/core-ai-tools.d.ts +14 -0
- package/dist/shell/lib/core-ai-tools.d.ts.map +1 -1
- package/dist/shell/lib/create-client-orders-provider.d.ts.map +1 -1
- package/dist/shell/lib/fayz-agent.d.ts +23 -0
- package/dist/shell/lib/fayz-agent.d.ts.map +1 -0
- package/dist/shell/lib/i18n.d.ts.map +1 -1
- package/dist/shell/stores/chat.store.d.ts +69 -0
- package/dist/shell/stores/chat.store.d.ts.map +1 -1
- package/dist/shell/types/billing.d.ts +7 -0
- package/dist/shell/types/billing.d.ts.map +1 -1
- package/dist/shell/types/crud.d.ts +1 -1
- package/dist/shell/types/crud.d.ts.map +1 -1
- package/dist/shell/types/plugins.d.ts +2 -45
- package/dist/shell/types/plugins.d.ts.map +1 -1
- package/dist/stores/createCrudStore.d.ts.map +1 -1
- package/package.json +15 -12
- package/src/access/context.tsx +228 -0
- package/src/access/headless.ts +71 -0
- package/src/access/index.ts +27 -0
- package/src/access/limits-registry.ts +86 -0
- package/src/access/module-nav.test.ts +78 -0
- package/src/access/module-nav.ts +82 -0
- package/src/access/resolver.test.ts +143 -0
- package/src/access/resolver.ts +5 -0
- package/src/access/types.ts +47 -0
- package/src/access/upgrade-modal-store.ts +30 -0
- package/src/app/AdminShell.tsx +451 -68
- package/src/app/admin-app.tsx +46 -9
- package/src/app/config.ts +54 -1
- package/src/app/core-entities.ts +110 -0
- package/src/app/derive-contract.test.ts +155 -0
- package/src/app/derive-contract.ts +214 -0
- package/src/app/scaffold.tsx +4 -0
- package/src/archetype-lookup.ts +8 -2
- package/src/billing/index.ts +2 -0
- package/src/billing/plan-badge.ts +32 -0
- package/src/billing/store.ts +8 -1
- package/src/components/shared/ContactPicker.tsx +394 -0
- package/src/components/shared/PersonLink.tsx +11 -3
- package/src/components/shared/create-person.ts +74 -0
- package/src/crud/CrudDetailPage.tsx +8 -1
- package/src/crud/CrudFormPage.tsx +124 -6
- package/src/crud/CrudListView.tsx +16 -1
- package/src/crud/CrudPage.tsx +27 -5
- package/src/crud/DeleteConfirmDialog.tsx +1 -1
- package/src/crud/archetypes/AddressesTab.tsx +387 -0
- package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
- package/src/crud/createCrudPage.tsx +2 -0
- package/src/crud/index.ts +6 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +49 -1
- package/src/org/store.ts +9 -1
- package/src/permissions/context.tsx +29 -15
- package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
- package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
- package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
- package/src/shell/components/billing/UpgradeModal.tsx +102 -0
- package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
- package/src/shell/components/billing/access-contract.ts +64 -0
- package/src/shell/components/billing/gates.tsx +62 -0
- package/src/shell/components/billing/index.ts +18 -2
- package/src/shell/components/chat/ChatFab.tsx +4 -2
- package/src/shell/components/chat/ChatPanel.tsx +174 -23
- package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
- package/src/shell/components/chat/markdown.tsx +102 -0
- package/src/shell/components/organization/TeamTab.tsx +21 -4
- package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
- package/src/shell/components/organization/index.ts +0 -1
- package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
- package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
- package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
- package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
- package/src/shell/components/settings/SettingsPage.tsx +63 -21
- package/src/shell/hooks/index.ts +3 -2
- package/src/shell/hooks/useAITools.ts +27 -23
- package/src/shell/hooks/useChat.ts +381 -16
- package/src/shell/hooks/useSystemPermission.ts +23 -0
- package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
- package/src/shell/lib/ai-tool-handlers.ts +910 -0
- package/src/shell/lib/core-ai-tools.ts +234 -2
- package/src/shell/lib/create-client-orders-provider.ts +24 -2
- package/src/shell/lib/fayz-agent.ts +56 -0
- package/src/shell/lib/i18n.ts +71 -0
- package/src/shell/stores/chat.store.ts +118 -1
- package/src/shell/styles.css +30 -0
- package/src/shell/types/billing.ts +8 -0
- package/src/shell/types/crud.ts +1 -1
- package/src/shell/types/plugins.ts +13 -50
- package/src/stores/createCrudStore.ts +9 -0
- package/dist/chunk-3M25GRRU.js.map +0 -1
- package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
- package/dist/chunk-5GYQK5IW.js +0 -21
- package/dist/chunk-5GYQK5IW.js.map +0 -1
- package/dist/chunk-IPWCHJPE.cjs.map +0 -1
- package/dist/chunk-KYID2MR6.cjs +0 -23
- package/dist/chunk-KYID2MR6.cjs.map +0 -1
- package/dist/chunk-XNWCGJYH.js.map +0 -1
- package/dist/shell/components/billing/BillingPage.d.ts +0 -7
- package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
- package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
- package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
- package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
- package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
- package/dist/shell/hooks/useBilling.d.ts +0 -14
- package/dist/shell/hooks/useBilling.d.ts.map +0 -1
- package/dist/shell/hooks/usePermission.d.ts +0 -16
- package/dist/shell/hooks/usePermission.d.ts.map +0 -1
- package/src/shell/components/billing/BillingPage.tsx +0 -56
- package/src/shell/components/billing/PaywallGate.tsx +0 -80
- package/src/shell/components/organization/PermissionGate.tsx +0 -16
- package/src/shell/hooks/useBilling.ts +0 -151
- package/src/shell/hooks/usePermission.ts +0 -101
package/src/app/AdminShell.tsx
CHANGED
|
@@ -13,28 +13,40 @@ import {
|
|
|
13
13
|
DropdownItem,
|
|
14
14
|
DropdownLabel,
|
|
15
15
|
DropdownSeparator,
|
|
16
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
if (!
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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={() =>
|
|
119
|
-
className="flex items-center gap-2
|
|
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
|
|
147
|
-
const initials =
|
|
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
|
|
152
|
-
<
|
|
153
|
-
{user.avatarUrl
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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-
|
|
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">
|
|
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">
|
|
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">
|
|
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
|
-
|
|
267
|
-
out.push({ path:
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
{ id: '
|
|
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
|
-
}
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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={
|
|
811
|
+
onProfile={onProfile}
|
|
812
|
+
onBilling={onBilling}
|
|
455
813
|
onSettings={() => navigateTo('/settings')}
|
|
456
|
-
|
|
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
|
-
:
|
|
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
|
>
|