@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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import type { LimitDeclaration, PermissionAction, Plan } from '@fayz-ai/core'
|
|
3
|
+
import { countByTenant, usePluginRuntimeOptional, getAllEntities } from '@fayz-ai/core'
|
|
4
|
+
import { useAuthStore } from '@fayz-ai/auth'
|
|
5
|
+
import { useOrganizationStore } from '../org/store'
|
|
6
|
+
import { usePermissionsStore } from '../permissions/store'
|
|
7
|
+
import { useBillingStore } from '../billing/store'
|
|
8
|
+
import { mergeLimitDeclarations } from '@fayz-ai/core/access'
|
|
9
|
+
import { resolveAccess, isEntitledByPlan, resolveLimit } from './resolver'
|
|
10
|
+
import { entityDerivedLimitDeclarations } from './limits-registry'
|
|
11
|
+
import { setLimitRegistry, getLimitDeclaration, subscribeLimit, CORE_LIMIT_DECLARATIONS } from './limits-registry'
|
|
12
|
+
import { useUpgradeModalStore } from './upgrade-modal-store'
|
|
13
|
+
import type { AccessApi, AccessDecision, AccessSession, LimitState } from './types'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Context
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
interface AccessContextValue extends AccessApi {
|
|
20
|
+
session: AccessSession
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const AccessContext = React.createContext<AccessContextValue | null>(null)
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Provider
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export interface AccessProviderProps {
|
|
30
|
+
children: React.ReactNode
|
|
31
|
+
/**
|
|
32
|
+
* App-level limit declarations layered ON TOP of the plugins' `declaredLimits`
|
|
33
|
+
* (app wins on key collision). Sourced from `config.billing.limitDeclarations`.
|
|
34
|
+
*/
|
|
35
|
+
limitDeclarations?: LimitDeclaration[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Assembles the reactive {@link AccessSession} from the singleton stores and
|
|
40
|
+
* exposes the composed access API (role × plan). Thin by design — mounts just
|
|
41
|
+
* inside PermissionsProvider so it can reuse the live permission profile and the
|
|
42
|
+
* plugin runtime. Also publishes the merged limit declarations to the module
|
|
43
|
+
* registry so `invalidateLimit` works from non-React create handlers.
|
|
44
|
+
*/
|
|
45
|
+
export function AccessProvider({ children, limitDeclarations }: AccessProviderProps) {
|
|
46
|
+
const user = useAuthStore((s) => s.user)
|
|
47
|
+
const org = useOrganizationStore((s) => s.currentOrg)
|
|
48
|
+
const profile = usePermissionsStore((s) => s.currentProfile)
|
|
49
|
+
const plans = useBillingStore((s) => s.plans)
|
|
50
|
+
|
|
51
|
+
const runtime = usePluginRuntimeOptional()
|
|
52
|
+
const pluginLimits = runtime?.pluginLimits
|
|
53
|
+
|
|
54
|
+
// Merge limit declarations by priority (later wins by key): shell built-ins
|
|
55
|
+
// (users/locations) < entity-derived < plugin `declaredLimits` < app
|
|
56
|
+
// overrides. Then publish to the module registry consumed by
|
|
57
|
+
// invalidateLimit / the limit hooks.
|
|
58
|
+
//
|
|
59
|
+
// Entity-derived: any registered CRUD entity carrying `limitKey` yields a
|
|
60
|
+
// declaration for free from its own data mapping (table + archetype kind) —
|
|
61
|
+
// this is what makes an app's `EntityDef.limitKey: 'clients'` enforceable
|
|
62
|
+
// with ZERO extra wiring (a cap without a countable binding is a dead cap).
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
setLimitRegistry(
|
|
65
|
+
mergeLimitDeclarations(
|
|
66
|
+
CORE_LIMIT_DECLARATIONS,
|
|
67
|
+
entityDerivedLimitDeclarations(getAllEntities()),
|
|
68
|
+
pluginLimits,
|
|
69
|
+
limitDeclarations,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
}, [pluginLimits, limitDeclarations])
|
|
73
|
+
|
|
74
|
+
const plan: Plan | null = React.useMemo(
|
|
75
|
+
() => (org?.plan ? plans.find((p) => p.id === org.plan) ?? null : null),
|
|
76
|
+
[plans, org?.plan],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const session: AccessSession = React.useMemo(
|
|
80
|
+
() => ({ user, org, profile, plan }),
|
|
81
|
+
[user, org, profile, plan],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const value = React.useMemo<AccessContextValue>(
|
|
85
|
+
() => ({
|
|
86
|
+
session,
|
|
87
|
+
can: (feature, action) => resolveAccess(session, feature, action),
|
|
88
|
+
entitled: (feature) => isEntitledByPlan(session.plan, feature),
|
|
89
|
+
}),
|
|
90
|
+
[session],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return <AccessContext.Provider value={value}>{children}</AccessContext.Provider>
|
|
94
|
+
}
|
|
95
|
+
AccessProvider.displayName = 'AccessProvider'
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Hooks
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const ALLOW_ALL: AccessApi = {
|
|
102
|
+
can: () => ({ allowed: true }),
|
|
103
|
+
entitled: () => true,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The composed access API. Throws outside <AccessProvider> — use
|
|
108
|
+
* {@link useAccessOptional} for SDK components that must degrade gracefully.
|
|
109
|
+
*/
|
|
110
|
+
export function useAccess(): AccessApi {
|
|
111
|
+
const ctx = React.useContext(AccessContext)
|
|
112
|
+
if (!ctx) throw new Error('useAccess must be used inside <AccessProvider>')
|
|
113
|
+
return { can: ctx.can, entitled: ctx.entitled }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Like {@link useAccess} but degrades to allow-all when no provider is mounted,
|
|
118
|
+
* so components stay usable under a bare host shell (gating is an enhancement).
|
|
119
|
+
*/
|
|
120
|
+
export function useAccessOptional(): AccessApi {
|
|
121
|
+
const ctx = React.useContext(AccessContext)
|
|
122
|
+
return ctx ? { can: ctx.can, entitled: ctx.entitled } : ALLOW_ALL
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Internal: the live session (for hooks that need tenant/plan directly). */
|
|
126
|
+
function useAccessSession(): AccessSession | null {
|
|
127
|
+
return React.useContext(AccessContext)?.session ?? null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Limits
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
function resolveCap(plan: Plan | null, key: string): number | undefined {
|
|
135
|
+
return plan?.entitlements?.limits?.[key]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Live usage of a quantity limit for the active tenant. Resolves the limit's
|
|
140
|
+
* declaration (plugin manifest or app override) → a tenant-scoped `count(*)`,
|
|
141
|
+
* capped by the current plan. No declaration or no cap (or `-1`) ⇒ unlimited.
|
|
142
|
+
* Re-fetches when the tenant, plan cap, or an `invalidateLimit(key)` fires.
|
|
143
|
+
*/
|
|
144
|
+
export function useLimit(key: string): LimitState {
|
|
145
|
+
const session = useAccessSession()
|
|
146
|
+
const tenantId = session?.org?.id
|
|
147
|
+
const cap = resolveCap(session?.plan ?? null, key)
|
|
148
|
+
const declaration = getLimitDeclaration(key)
|
|
149
|
+
|
|
150
|
+
const unlimited = !declaration || cap === undefined || cap === -1
|
|
151
|
+
|
|
152
|
+
const [used, setUsed] = React.useState(0)
|
|
153
|
+
const [loading, setLoading] = React.useState(!unlimited)
|
|
154
|
+
const [version, setVersion] = React.useState(0)
|
|
155
|
+
|
|
156
|
+
const refresh = React.useCallback(() => setVersion((v) => v + 1), [])
|
|
157
|
+
|
|
158
|
+
// Wake on external invalidation (a create/delete elsewhere).
|
|
159
|
+
React.useEffect(() => subscribeLimit(key, refresh), [key, refresh])
|
|
160
|
+
|
|
161
|
+
React.useEffect(() => {
|
|
162
|
+
if (unlimited || !declaration || !tenantId) {
|
|
163
|
+
setUsed(0)
|
|
164
|
+
setLoading(false)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
let active = true
|
|
168
|
+
setLoading(true)
|
|
169
|
+
countByTenant(declaration.table, {
|
|
170
|
+
kind: declaration.kindFilter,
|
|
171
|
+
period: declaration.period,
|
|
172
|
+
tenantId,
|
|
173
|
+
fresh: version > 0,
|
|
174
|
+
})
|
|
175
|
+
.then((n) => {
|
|
176
|
+
if (active) {
|
|
177
|
+
setUsed(n)
|
|
178
|
+
setLoading(false)
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
.catch(() => {
|
|
182
|
+
if (active) setLoading(false)
|
|
183
|
+
})
|
|
184
|
+
return () => {
|
|
185
|
+
active = false
|
|
186
|
+
}
|
|
187
|
+
}, [unlimited, declaration, tenantId, version])
|
|
188
|
+
|
|
189
|
+
const bounds = resolveLimit({ cap, hasDeclaration: Boolean(declaration), used })
|
|
190
|
+
|
|
191
|
+
return { key, ...bounds, loading, refresh }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Imperative guard for create handlers: `await guard(n)` returns `'ok'` when
|
|
196
|
+
* adding `n` more rows stays within the cap, or `'blocked'` (and opens the global
|
|
197
|
+
* UpgradeModal) when it would exceed it. Always checks a FRESH count so it never
|
|
198
|
+
* relies on stale hook state.
|
|
199
|
+
*/
|
|
200
|
+
export function useLimitGuard(key: string): (n?: number) => Promise<'ok' | 'blocked'> {
|
|
201
|
+
const session = useAccessSession()
|
|
202
|
+
const tenantId = session?.org?.id
|
|
203
|
+
const plan = session?.plan ?? null
|
|
204
|
+
const openModal = useUpgradeModalStore((s) => s.open)
|
|
205
|
+
|
|
206
|
+
return React.useCallback(
|
|
207
|
+
async (n = 1) => {
|
|
208
|
+
const cap = resolveCap(plan, key)
|
|
209
|
+
const declaration = getLimitDeclaration(key)
|
|
210
|
+
// Unlimited / no declaration / no tenant ⇒ never block.
|
|
211
|
+
if (!declaration || cap === undefined || cap === -1 || !tenantId) return 'ok'
|
|
212
|
+
|
|
213
|
+
const used = await countByTenant(declaration.table, {
|
|
214
|
+
kind: declaration.kindFilter,
|
|
215
|
+
period: declaration.period,
|
|
216
|
+
tenantId,
|
|
217
|
+
fresh: true,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
if (used + n > cap) {
|
|
221
|
+
openModal({ limitKey: key })
|
|
222
|
+
return 'blocked'
|
|
223
|
+
}
|
|
224
|
+
return 'ok'
|
|
225
|
+
},
|
|
226
|
+
[key, plan, tenantId, openModal],
|
|
227
|
+
)
|
|
228
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { PermissionAction, Plan } from '@fayz-ai/core'
|
|
2
|
+
import { countByTenant } from '@fayz-ai/core'
|
|
3
|
+
import { resolveAccess, agentDenial, type AgentGuardResult } from '@fayz-ai/core/access'
|
|
4
|
+
import { useAuthStore } from '@fayz-ai/auth'
|
|
5
|
+
import { useOrganizationStore } from '../org/store'
|
|
6
|
+
import { usePermissionsStore } from '../permissions/store'
|
|
7
|
+
import { useBillingStore } from '../billing/store'
|
|
8
|
+
import { getLimitDeclaration } from './limits-registry'
|
|
9
|
+
import type { AccessSession } from './types'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Headless access — the imperative counterpart of useAccess()/useLimitGuard()
|
|
13
|
+
// for runtimes without React: the agent's client-plane tool executor, command
|
|
14
|
+
// handlers, background sync. Reads the SAME singleton stores the providers
|
|
15
|
+
// hydrate, and decides with the SAME engine (@fayz-ai/core/access), so a
|
|
16
|
+
// decision here never diverges from what the UI shows.
|
|
17
|
+
//
|
|
18
|
+
// Denials come back as the structured AgentDenial (never a silent failure) so
|
|
19
|
+
// an agent can explain the denial and offer the upgrade conversationally.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Snapshot of the live session assembled from the singleton stores — the same
|
|
24
|
+
* assembly AccessProvider does reactively. Values are as-hydrated: before
|
|
25
|
+
* AuthProvider/OrgProvider finish, fields are null (and decisions fail-open on
|
|
26
|
+
* profile, closed on nothing — same semantics as the providers).
|
|
27
|
+
*/
|
|
28
|
+
export function getAccessSnapshot(): AccessSession {
|
|
29
|
+
const user = useAuthStore.getState().user
|
|
30
|
+
const org = useOrganizationStore.getState().currentOrg
|
|
31
|
+
const profile = usePermissionsStore.getState().currentProfile
|
|
32
|
+
const plans = useBillingStore.getState().plans
|
|
33
|
+
const plan: Plan | null = org?.plan ? plans.find((p) => p.id === org.plan) ?? null : null
|
|
34
|
+
return { user, org, profile, plan }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Imperative role × plan check over the live snapshot. */
|
|
38
|
+
export function checkAccess(feature: string, action?: PermissionAction): AgentGuardResult {
|
|
39
|
+
const session = getAccessSnapshot()
|
|
40
|
+
const decision = resolveAccess(session, feature, action)
|
|
41
|
+
if (decision.allowed) return { allowed: true }
|
|
42
|
+
return agentDenial(decision.reason ?? 'role')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Imperative plan-limit guard: would adding `n` rows exceed the cap for
|
|
47
|
+
* `key`? Counts FRESH (never trusts stale hook state), mirrors useLimitGuard.
|
|
48
|
+
* No declaration / no cap / `-1` / no tenant ⇒ allowed. Does NOT open the
|
|
49
|
+
* UpgradeModal — the caller decides how to surface the denial (an agent turns
|
|
50
|
+
* it into a conversational upgrade prompt).
|
|
51
|
+
*/
|
|
52
|
+
export async function guardLimit(key: string, n = 1): Promise<AgentGuardResult> {
|
|
53
|
+
const session = getAccessSnapshot()
|
|
54
|
+
const tenantId = session.org?.id
|
|
55
|
+
const cap = session.plan?.entitlements?.limits?.[key]
|
|
56
|
+
const declaration = getLimitDeclaration(key)
|
|
57
|
+
|
|
58
|
+
if (!declaration || cap === undefined || cap === -1 || !tenantId) return { allowed: true }
|
|
59
|
+
|
|
60
|
+
const used = await countByTenant(declaration.table, {
|
|
61
|
+
kind: declaration.kindFilter,
|
|
62
|
+
period: declaration.period,
|
|
63
|
+
tenantId,
|
|
64
|
+
fresh: true,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (used + n > cap) {
|
|
68
|
+
return agentDenial('limit', { key, max: cap, used })
|
|
69
|
+
}
|
|
70
|
+
return { allowed: true }
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Access engine — the single point of access decision composing RBAC role
|
|
2
|
+
// (can?) × plan entitlements (entitled? how many?). See resolver.ts for the
|
|
3
|
+
// pure composition and context.tsx for the reactive session wiring.
|
|
4
|
+
|
|
5
|
+
export { AccessProvider, useAccess, useAccessOptional, useLimit, useLimitGuard } from './context'
|
|
6
|
+
export type { AccessProviderProps } from './context'
|
|
7
|
+
|
|
8
|
+
export { resolveAccess, isEntitledByPlan } from './resolver'
|
|
9
|
+
|
|
10
|
+
export { getAccessSnapshot, checkAccess, guardLimit } from './headless'
|
|
11
|
+
export { agentDenial, UPGRADE_URL } from '@fayz-ai/core/access'
|
|
12
|
+
export type { AgentDenial, AgentDenialLimit, AgentGuardResult } from '@fayz-ai/core/access'
|
|
13
|
+
|
|
14
|
+
export { invalidateLimit, setLimitRegistry, getLimitDeclaration, CORE_LIMIT_DECLARATIONS } from './limits-registry'
|
|
15
|
+
|
|
16
|
+
export { useModuleNavAccess, applyNavAccess } from './module-nav'
|
|
17
|
+
|
|
18
|
+
export { useUpgradeModalStore } from './upgrade-modal-store'
|
|
19
|
+
export type { UpgradeModalPayload, UpgradeModalStore } from './upgrade-modal-store'
|
|
20
|
+
|
|
21
|
+
export type {
|
|
22
|
+
AccessApi,
|
|
23
|
+
AccessDecision,
|
|
24
|
+
AccessSession,
|
|
25
|
+
DenyReason,
|
|
26
|
+
LimitState,
|
|
27
|
+
} from './types'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { LimitDeclaration, RegisteredEntity } from '@fayz-ai/core'
|
|
2
|
+
import { invalidateCount } from '@fayz-ai/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Module-level registry bridging React (the provider that knows the declarations)
|
|
6
|
+
* and the plain `invalidateLimit(key)` function that create handlers call outside
|
|
7
|
+
* any component. The AccessProvider publishes the merged declarations here on
|
|
8
|
+
* mount; `useLimit`/`useLimitGuard` and `invalidateLimit` read from it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shell built-in limit declarations. Unlike plugin/app limits (contributed via
|
|
13
|
+
* `PluginManifest.declaredLimits` / `billing.limitDeclarations`), these count
|
|
14
|
+
* core tenant tables that have no owning plugin: seats (`tenant_members`) and
|
|
15
|
+
* `locations`. Merged as the LOWEST-priority layer by the AccessProvider, so a
|
|
16
|
+
* plugin or app may still override a key (e.g. add a `kindFilter`).
|
|
17
|
+
*/
|
|
18
|
+
export const CORE_LIMIT_DECLARATIONS: LimitDeclaration[] = [
|
|
19
|
+
{ key: 'users', label: 'Users', table: 'tenant_members' },
|
|
20
|
+
{ key: 'locations', label: 'Locations', table: 'locations' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Entity-derived layer of the limit merge: any registered CRUD entity carrying
|
|
25
|
+
* `limitKey` yields a declaration for free from its own data mapping (table +
|
|
26
|
+
* archetype kind) — a cap without a countable binding is a dead cap. Shared by
|
|
27
|
+
* the AccessProvider (live registry) and the manifest derivation (agent
|
|
28
|
+
* contract), so both resolve the identical layer.
|
|
29
|
+
*/
|
|
30
|
+
export function entityDerivedLimitDeclarations(entities: RegisteredEntity[]): LimitDeclaration[] {
|
|
31
|
+
const out: LimitDeclaration[] = []
|
|
32
|
+
for (const e of entities) {
|
|
33
|
+
const def = e.entityDef
|
|
34
|
+
if (def?.limitKey && def.data?.table) {
|
|
35
|
+
out.push({
|
|
36
|
+
key: def.limitKey,
|
|
37
|
+
label: e.labelPlural,
|
|
38
|
+
table: def.data.table,
|
|
39
|
+
kindFilter: def.data.archetypeKind,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let registry = new Map<string, LimitDeclaration>()
|
|
47
|
+
|
|
48
|
+
/** Called by AccessProvider whenever the merged limit declarations change. */
|
|
49
|
+
export function setLimitRegistry(declarations: LimitDeclaration[]): void {
|
|
50
|
+
const next = new Map<string, LimitDeclaration>()
|
|
51
|
+
for (const decl of declarations) next.set(decl.key, decl)
|
|
52
|
+
registry = next
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getLimitDeclaration(key: string): LimitDeclaration | undefined {
|
|
56
|
+
return registry.get(key)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Invalidation pub/sub -------------------------------------------------
|
|
60
|
+
// useLimit subscribes per key; invalidateLimit(key) drops the cached count for
|
|
61
|
+
// the declaration's table and notifies subscribers to re-fetch.
|
|
62
|
+
|
|
63
|
+
const listeners = new Map<string, Set<() => void>>()
|
|
64
|
+
|
|
65
|
+
export function subscribeLimit(key: string, cb: () => void): () => void {
|
|
66
|
+
let set = listeners.get(key)
|
|
67
|
+
if (!set) {
|
|
68
|
+
set = new Set()
|
|
69
|
+
listeners.set(key, set)
|
|
70
|
+
}
|
|
71
|
+
set.add(cb)
|
|
72
|
+
return () => {
|
|
73
|
+
set?.delete(cb)
|
|
74
|
+
if (set && set.size === 0) listeners.delete(key)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Invalidate a limit after a create/delete so its next read is fresh, then wake
|
|
80
|
+
* any mounted `useLimit(key)`. Safe to call from plain handlers (no React).
|
|
81
|
+
*/
|
|
82
|
+
export function invalidateLimit(key: string): void {
|
|
83
|
+
const decl = registry.get(key)
|
|
84
|
+
if (decl) invalidateCount(decl.table)
|
|
85
|
+
listeners.get(key)?.forEach((cb) => cb())
|
|
86
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { ModuleNavItem } from '@fayz-ai/ui'
|
|
3
|
+
import { applyNavAccess } from './module-nav'
|
|
4
|
+
import type { AccessApi } from './types'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Fixtures — a fake AccessApi so the pure decoration is testable without React
|
|
8
|
+
// or the live stores. `roleDenied` names features the role can't read;
|
|
9
|
+
// `planDenied` names features the plan doesn't entitle.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
function fakeAccess(opts: { roleDenied?: string[]; planDenied?: string[] }): AccessApi {
|
|
13
|
+
const roleDenied = new Set(opts.roleDenied ?? [])
|
|
14
|
+
const planDenied = new Set(opts.planDenied ?? [])
|
|
15
|
+
return {
|
|
16
|
+
can: (feature) =>
|
|
17
|
+
roleDenied.has(feature) ? { allowed: false, reason: 'role' } : { allowed: true },
|
|
18
|
+
entitled: (feature) => !planDenied.has(feature),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function item(id: string, feature?: string, children?: ModuleNavItem[]): ModuleNavItem {
|
|
23
|
+
return { id, label: id, feature, children }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// applyNavAccess
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe('applyNavAccess', () => {
|
|
31
|
+
const known = new Set(['fin_reconciliation', 'fin_cards', 'fin_commissions'])
|
|
32
|
+
|
|
33
|
+
it('removes a link the role denies (declared feature)', () => {
|
|
34
|
+
const nav = [item('summary'), item('reconciliation', 'fin_reconciliation')]
|
|
35
|
+
const out = applyNavAccess(nav, fakeAccess({ roleDenied: ['fin_reconciliation'] }), known)
|
|
36
|
+
expect(out.map((i) => i.id)).toEqual(['summary'])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('badges a plan-denied link premium but keeps it navigable', () => {
|
|
40
|
+
const nav = [item('reconciliation', 'fin_reconciliation')]
|
|
41
|
+
const out = applyNavAccess(nav, fakeAccess({ planDenied: ['fin_reconciliation'] }), known)
|
|
42
|
+
expect(out).toHaveLength(1)
|
|
43
|
+
expect(out[0].premium).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('passes an ABSENT feature through untouched (default allow — no remove, no crown)', () => {
|
|
47
|
+
// `fin_ghost` isn't in the RBAC catalog → the role axis is skipped even
|
|
48
|
+
// though a bare `can` would report a role denial for it.
|
|
49
|
+
const nav = [item('ghost', 'fin_ghost')]
|
|
50
|
+
const out = applyNavAccess(nav, fakeAccess({ roleDenied: ['fin_ghost'] }), known)
|
|
51
|
+
expect(out).toHaveLength(1)
|
|
52
|
+
expect(out[0].premium).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('leaves featureless links (and their id/onClick) untouched', () => {
|
|
56
|
+
const nav = [item('summary')]
|
|
57
|
+
const out = applyNavAccess(nav, fakeAccess({ roleDenied: ['x'], planDenied: ['y'] }), known)
|
|
58
|
+
expect(out).toEqual(nav)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('recurses into children — removes role-denied, crowns plan-denied', () => {
|
|
62
|
+
const nav = [
|
|
63
|
+
item('cards', 'fin_cards', [
|
|
64
|
+
item('cards-overview'),
|
|
65
|
+
item('cards-secret', 'fin_reconciliation'), // role-denied child
|
|
66
|
+
item('cards-premium', 'fin_commissions'), // plan-denied child
|
|
67
|
+
]),
|
|
68
|
+
]
|
|
69
|
+
const out = applyNavAccess(
|
|
70
|
+
nav,
|
|
71
|
+
fakeAccess({ roleDenied: ['fin_reconciliation'], planDenied: ['fin_commissions'] }),
|
|
72
|
+
known,
|
|
73
|
+
)
|
|
74
|
+
const parent = out[0]
|
|
75
|
+
expect(parent.children!.map((c) => c.id)).toEqual(['cards-overview', 'cards-premium'])
|
|
76
|
+
expect(parent.children!.find((c) => c.id === 'cards-premium')!.premium).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import type { ModuleNavItem } from '@fayz-ai/ui'
|
|
3
|
+
import { usePermissionsStore } from '../permissions/store'
|
|
4
|
+
import { useAccessOptional } from './context'
|
|
5
|
+
import type { AccessApi } from './types'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// useModuleNavAccess — the LINK-side counterpart of EntitlementGate for a
|
|
9
|
+
// plugin's INTERNAL navigation (ModulePage sub-nav). Mirrors the top-level
|
|
10
|
+
// Sidebar/AdminShell rule so a premium sub-module link reads identically:
|
|
11
|
+
//
|
|
12
|
+
// • role denies (can(feature,'read').reason === 'role') → REMOVE the link
|
|
13
|
+
// • plan denies (entitled(feature) === false) → keep, premium:true
|
|
14
|
+
// (the click still navigates; the gated CONTENT shows the
|
|
15
|
+
// UpgradePrompt via EntitlementGate)
|
|
16
|
+
// • otherwise → keep as-is
|
|
17
|
+
//
|
|
18
|
+
// A feature id ABSENT from the RBAC catalog is treated as "default allow": the
|
|
19
|
+
// role axis is skipped entirely for it (an undeclared feature can't be a role
|
|
20
|
+
// denial), so apps that wire the plugin without declaring the sub-feature don't
|
|
21
|
+
// lose the link. The plan axis is naturally safe already — `entitled` returns
|
|
22
|
+
// true for any feature a plan never disables.
|
|
23
|
+
//
|
|
24
|
+
// Recursive over `children`. Degrades to allow-all when no <AccessProvider> is
|
|
25
|
+
// mounted (bare host shell), exactly like `useAccessOptional`.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pure decoration used by {@link useModuleNavAccess}. Kept store-free so it is
|
|
30
|
+
* directly unit-testable: pass the composed {@link AccessApi} and the set of
|
|
31
|
+
* feature ids known to the RBAC catalog.
|
|
32
|
+
*/
|
|
33
|
+
export function applyNavAccess(
|
|
34
|
+
items: ModuleNavItem[],
|
|
35
|
+
access: AccessApi,
|
|
36
|
+
knownFeatures: Set<string>,
|
|
37
|
+
): ModuleNavItem[] {
|
|
38
|
+
const decorate = (item: ModuleNavItem): ModuleNavItem | null => {
|
|
39
|
+
const children = item.children
|
|
40
|
+
? item.children
|
|
41
|
+
.map(decorate)
|
|
42
|
+
.filter((c): c is ModuleNavItem => c !== null)
|
|
43
|
+
: undefined
|
|
44
|
+
|
|
45
|
+
if (!item.feature) {
|
|
46
|
+
return children ? { ...item, children } : item
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Role axis — only meaningful for a feature the RBAC catalog actually
|
|
50
|
+
// declares. An undeclared feature defaults to allow (never removed).
|
|
51
|
+
if (knownFeatures.has(item.feature)) {
|
|
52
|
+
const decision = access.can(item.feature, 'read')
|
|
53
|
+
if (!decision.allowed && decision.reason === 'role') return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Plan axis — `entitled` is `false` only on an explicit plan denial; absent
|
|
57
|
+
// features stay entitled, so no Crown appears for them.
|
|
58
|
+
const premium = access.entitled(item.feature) === false
|
|
59
|
+
|
|
60
|
+
return children ? { ...item, premium, children } : { ...item, premium }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return items.map(decorate).filter((i): i is ModuleNavItem => i !== null)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a plugin's internal nav against role × plan: hides role-denied links,
|
|
68
|
+
* badges plan-denied ones with a Crown (`premium`), and passes everything else
|
|
69
|
+
* through unchanged. Recursive over children.
|
|
70
|
+
*/
|
|
71
|
+
export function useModuleNavAccess(items: ModuleNavItem[]): ModuleNavItem[] {
|
|
72
|
+
const access = useAccessOptional()
|
|
73
|
+
const features = usePermissionsStore((s) => s.features)
|
|
74
|
+
const knownFeatures = React.useMemo(
|
|
75
|
+
() => new Set(features.map((f) => f.id)),
|
|
76
|
+
[features],
|
|
77
|
+
)
|
|
78
|
+
return React.useMemo(
|
|
79
|
+
() => applyNavAccess(items, access, knownFeatures),
|
|
80
|
+
[items, access, knownFeatures],
|
|
81
|
+
)
|
|
82
|
+
}
|