snipe-auth-rbac 0.3.0 → 0.4.0
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/admin/index.cjs +201 -11
- package/dist/admin/index.cjs.map +1 -1
- package/dist/admin/index.d.cts +112 -3
- package/dist/admin/index.d.ts +112 -3
- package/dist/admin/index.js +200 -12
- package/dist/admin/index.js.map +1 -1
- package/dist/{chunk-NRDW233A.js → chunk-5UAIIOKT.js} +65 -1
- package/dist/chunk-5UAIIOKT.js.map +1 -0
- package/dist/{chunk-C76JHCKM.js → chunk-XHPBUCFN.js} +33 -1
- package/dist/chunk-XHPBUCFN.js.map +1 -0
- package/dist/index-CJqb5nY5.d.cts +191 -0
- package/dist/index-nfrns9Ye.d.ts +191 -0
- package/dist/index.cjs +42 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -118
- package/dist/index.d.ts +3 -118
- package/dist/index.js +4 -2
- package/dist/react/index.cjs +106 -11
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +34 -59
- package/dist/react/index.d.ts +34 -59
- package/dist/react/index.js +14 -13
- package/dist/react/index.js.map +1 -1
- package/dist/types-Oj9yfWvz.d.cts +132 -0
- package/dist/types-Oj9yfWvz.d.ts +132 -0
- package/package.json +1 -1
- package/sql/0001_initial.sql +137 -2
- package/sql/0002_seed_defaults.sql +29 -19
- package/dist/chunk-C76JHCKM.js.map +0 -1
- package/dist/chunk-NRDW233A.js.map +0 -1
- package/dist/types-DxvFudPF.d.cts +0 -69
- package/dist/types-DxvFudPF.d.ts +0 -69
package/dist/react/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
RbacRegistryError,
|
|
2
3
|
createHttpFetcher,
|
|
3
4
|
createSupabaseFetcher,
|
|
5
|
+
defineAuthRbac,
|
|
4
6
|
detectRbacSchema
|
|
5
|
-
} from "../chunk-
|
|
7
|
+
} from "../chunk-5UAIIOKT.js";
|
|
6
8
|
import {
|
|
7
9
|
buildPermissionResolver
|
|
8
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-XHPBUCFN.js";
|
|
9
11
|
|
|
10
12
|
// src/react/AuthRbacProvider.tsx
|
|
11
13
|
import {
|
|
@@ -89,6 +91,7 @@ function AuthRbacProvider(props) {
|
|
|
89
91
|
if (profile == null) {
|
|
90
92
|
return {
|
|
91
93
|
can: () => false,
|
|
94
|
+
canAccessSection: () => false,
|
|
92
95
|
activePermissions: () => ({}),
|
|
93
96
|
systemPermissions: () => ({})
|
|
94
97
|
};
|
|
@@ -135,6 +138,12 @@ function useCan(resource, action, options) {
|
|
|
135
138
|
return resolver.can(resource, action, options);
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
// src/react/useCanAccessSection.ts
|
|
142
|
+
function useCanAccessSection(resource, action = "read", options) {
|
|
143
|
+
const { resolver } = useAuthRbac();
|
|
144
|
+
return resolver.canAccessSection(resource, action, options);
|
|
145
|
+
}
|
|
146
|
+
|
|
138
147
|
// src/react/Can.tsx
|
|
139
148
|
import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
|
|
140
149
|
function Can(props) {
|
|
@@ -194,21 +203,11 @@ function useFrontendConfig() {
|
|
|
194
203
|
}, [profile, activeCompanyId]);
|
|
195
204
|
}
|
|
196
205
|
|
|
197
|
-
// src/define.ts
|
|
198
|
-
function defineAuthRbac(resources, runtime) {
|
|
199
|
-
return {
|
|
200
|
-
useCan: runtime.useCan,
|
|
201
|
-
Can: runtime.Can,
|
|
202
|
-
RequirePermission: runtime.RequirePermission,
|
|
203
|
-
resources,
|
|
204
|
-
resourceNames: resources.map((r) => r.resource)
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
206
|
// src/react/index.ts
|
|
209
207
|
function defineAuthRbac2(resources) {
|
|
210
208
|
return defineAuthRbac(resources, {
|
|
211
209
|
useCan,
|
|
210
|
+
useCanAccessSection,
|
|
212
211
|
Can,
|
|
213
212
|
RequirePermission
|
|
214
213
|
});
|
|
@@ -216,6 +215,7 @@ function defineAuthRbac2(resources) {
|
|
|
216
215
|
export {
|
|
217
216
|
AuthRbacProvider,
|
|
218
217
|
Can,
|
|
218
|
+
RbacRegistryError,
|
|
219
219
|
RequirePermission,
|
|
220
220
|
createHttpFetcher,
|
|
221
221
|
createSupabaseFetcher,
|
|
@@ -224,6 +224,7 @@ export {
|
|
|
224
224
|
useActiveCompany,
|
|
225
225
|
useAuthRbac,
|
|
226
226
|
useCan,
|
|
227
|
+
useCanAccessSection,
|
|
227
228
|
useFrontendConfig
|
|
228
229
|
};
|
|
229
230
|
//# sourceMappingURL=index.js.map
|
package/dist/react/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/react/AuthRbacProvider.tsx","../../src/react/useCan.ts","../../src/react/Can.tsx","../../src/react/RequirePermission.tsx","../../src/react/useActiveCompany.ts","../../src/react/useFrontendConfig.ts","../../src/define.ts","../../src/react/index.ts"],"sourcesContent":["import {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from \"react\";\n\nimport {\n buildPermissionResolver,\n type AuthRbacClient,\n} from \"../client.js\";\nimport type {\n AuthRbacFetcher,\n PermissionMap,\n ResourceRegistry,\n UserProfile,\n} from \"../types.js\";\n\ninterface AuthRbacContextValue {\n /**\n * `null` means we haven't hydrated yet. Components should treat\n * the not-loaded state as \"no permissions\" (fail-closed).\n */\n profile: UserProfile | null;\n loading: boolean;\n error: Error | null;\n resources: ResourceRegistry;\n activeCompanyId: string | null;\n setActiveCompany: (id: string | null) => void;\n refresh: () => Promise<void>;\n resolver: AuthRbacClient;\n}\n\nconst AuthRbacContext = createContext<AuthRbacContextValue | null>(null);\n\nexport interface AuthRbacProviderProps {\n fetcher: AuthRbacFetcher;\n resources: ResourceRegistry;\n /**\n * Initial active company. Common patterns:\n * - read from URL query/path\n * - read from localStorage\n * - omit and let the user pick from the switcher\n */\n initialCompanyId?: string | null;\n /**\n * Persistence hook. Called every time the active company changes.\n * Default: writes to `localStorage` under\n * `auth-rbac:active-company`. Pass `false` to disable.\n */\n persistActiveCompany?:\n | ((id: string | null) => void)\n | false;\n children: ReactNode;\n}\n\nconst STORAGE_KEY = \"auth-rbac:active-company\";\n\nconst defaultPersist = (id: string | null) => {\n if (typeof window === \"undefined\") {\n return;\n }\n try {\n if (id == null) {\n window.localStorage.removeItem(STORAGE_KEY);\n } else {\n window.localStorage.setItem(STORAGE_KEY, id);\n }\n } catch {\n // localStorage may be unavailable (private browsing, SSR) —\n // fall back to in-memory only.\n }\n};\n\nconst readPersisted = (): string | null => {\n if (typeof window === \"undefined\") {\n return null;\n }\n try {\n return window.localStorage.getItem(STORAGE_KEY);\n } catch {\n return null;\n }\n};\n\nexport function AuthRbacProvider(props: AuthRbacProviderProps) {\n const { fetcher, resources, initialCompanyId, persistActiveCompany } = props;\n\n const [profile, setProfile] = useState<UserProfile | null>(null);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const [activeCompanyId, setActiveCompanyState] = useState<string | null>(\n initialCompanyId ?? readPersisted(),\n );\n\n const persist = useMemo(() => {\n if (persistActiveCompany === false) {\n return () => {};\n }\n return persistActiveCompany ?? defaultPersist;\n }, [persistActiveCompany]);\n\n const setActiveCompany = useCallback(\n (id: string | null) => {\n setActiveCompanyState(id);\n persist(id);\n },\n [persist],\n );\n\n const refresh = useCallback(async () => {\n setLoading(true);\n setError(null);\n try {\n const next = await fetcher.fetchProfile();\n setProfile(next);\n // If the persisted company isn't a membership, fall back to\n // the first one (or null for users with no memberships).\n const stillMember =\n activeCompanyId != null &&\n next.memberships.some((m) => m.company_id === activeCompanyId);\n if (!stillMember) {\n const fallback =\n next.memberships[0]?.company_id ?? null;\n setActiveCompanyState(fallback);\n persist(fallback);\n }\n } catch (e) {\n setError(e instanceof Error ? e : new Error(String(e)));\n } finally {\n setLoading(false);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [fetcher]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n const resolver = useMemo<AuthRbacClient>(() => {\n if (profile == null) {\n // Empty resolver until the profile lands. Always returns\n // false → guards fall through to the unauthenticated branch.\n return {\n can: () => false,\n activePermissions: () => ({}) as PermissionMap,\n systemPermissions: () => ({}) as PermissionMap,\n };\n }\n return buildPermissionResolver(resources, profile, activeCompanyId);\n }, [profile, resources, activeCompanyId]);\n\n const value = useMemo<AuthRbacContextValue>(\n () => ({\n profile,\n loading,\n error,\n resources,\n activeCompanyId,\n setActiveCompany,\n refresh,\n resolver,\n }),\n [\n profile,\n loading,\n error,\n resources,\n activeCompanyId,\n setActiveCompany,\n refresh,\n resolver,\n ],\n );\n\n return (\n <AuthRbacContext.Provider value={value}>\n {props.children}\n </AuthRbacContext.Provider>\n );\n}\n\nexport function useAuthRbac(): AuthRbacContextValue {\n const ctx = useContext(AuthRbacContext);\n if (!ctx) {\n throw new Error(\n \"useAuthRbac must be used within an <AuthRbacProvider> — wrap your app at the root.\",\n );\n }\n return ctx;\n}\n","import type { Action } from \"../types.js\";\nimport type { CanOptions } from \"../client.js\";\n\nimport { useAuthRbac } from \"./AuthRbacProvider.js\";\n\n/**\n * Boolean permission check.\n *\n * @example\n * const canEdit = useCan(\"properties\", \"update\");\n * <Button disabled={!canEdit}>Speichern</Button>\n *\n * @example explicitly target a non-active company\n * const canRead = useCan(\"payments\", \"read\", { companyId: targetId });\n */\nexport function useCan(\n resource: string,\n action: Action,\n options?: CanOptions,\n): boolean {\n const { resolver } = useAuthRbac();\n return resolver.can(resource, action, options);\n}\n","import type { ReactNode } from \"react\";\n\nimport type { Action } from \"../types.js\";\nimport type { CanOptions } from \"../client.js\";\n\nimport { useCan } from \"./useCan.js\";\n\nexport interface CanProps extends CanOptions {\n resource: string;\n action: Action;\n /** Rendered when the user has the permission. */\n children: ReactNode;\n /**\n * Rendered when the user does NOT have the permission. Defaults\n * to `null` (silent hide). Pass a `<NoPermissionView />` or a\n * tooltip-wrapper to surface the denial explicitly.\n */\n fallback?: ReactNode;\n}\n\n/**\n * Subtree gate. Bails before children render so any data fetching\n * inside `children` is skipped for users without permission.\n */\nexport function Can(props: CanProps) {\n const { resource, action, companyId, children, fallback = null } = props;\n const allowed = useCan(resource, action, { companyId });\n return <>{allowed ? children : fallback}</>;\n}\n","import type { ReactNode } from \"react\";\n\nimport type { Action } from \"../types.js\";\nimport type { CanOptions } from \"../client.js\";\n\nimport { useAuthRbac } from \"./AuthRbacProvider.js\";\nimport { useCan } from \"./useCan.js\";\n\nexport interface RequirePermissionProps extends CanOptions {\n resource: string;\n action: Action;\n /**\n * What to render while the profile is still loading. Defaults to\n * `null` (no flash) — pass a spinner if your routes typically\n * mount before the profile lands.\n */\n loadingFallback?: ReactNode;\n /**\n * What to render when access is denied. Defaults to a minimal\n * \"Sie haben keinen Zugriff\" message; pass your own component to\n * theme it.\n */\n deniedFallback?: ReactNode;\n /**\n * For `react-router-dom v6` route-element usage, pass an `<Outlet />`\n * here — the gate resolves to either the outlet or the denied\n * fallback. For component-tree usage, pass any children.\n */\n children?: ReactNode;\n}\n\n/**\n * Route- or component-level guard. Three render branches:\n *\n * - profile not yet loaded → `loadingFallback`\n * - permission denied → `deniedFallback`\n * - permission granted → `children`\n *\n * Drop-in replacement for the legacy `<RequireRolesRoute>` pattern.\n *\n * @example\n * // App.tsx route table\n * <Route element={\n * <RequirePermission resource=\"payments\" action=\"read\">\n * <Outlet />\n * </RequirePermission>\n * }>\n * <Route path=\"/payments\" element={<PaymentsPage />} />\n * </Route>\n */\nexport function RequirePermission(props: RequirePermissionProps) {\n const {\n resource,\n action,\n companyId,\n loadingFallback = null,\n deniedFallback = (\n <div role=\"alert\" style={{ padding: 24 }}>\n <strong>Sie haben keinen Zugriff.</strong>\n </div>\n ),\n children = null,\n } = props;\n\n const { profile, loading } = useAuthRbac();\n const allowed = useCan(resource, action, { companyId });\n\n if (loading || profile == null) {\n return <>{loadingFallback}</>;\n }\n if (!allowed) {\n return <>{deniedFallback}</>;\n }\n return <>{children}</>;\n}\n","import { useMemo } from \"react\";\n\nimport type { CompanyMembership } from \"../types.js\";\n\nimport { useAuthRbac } from \"./AuthRbacProvider.js\";\n\nexport interface ActiveCompany {\n id: string | null;\n membership: CompanyMembership | null;\n memberships: CompanyMembership[];\n setActive: (id: string | null) => void;\n}\n\n/**\n * Read + switch the active company.\n *\n * @example\n * const { id, memberships, setActive } = useActiveCompany();\n *\n * return (\n * <select value={id ?? \"\"} onChange={(e) => setActive(e.target.value || null)}>\n * {memberships.map((m) => (\n * <option key={m.company_id} value={m.company_id}>{m.company_name}</option>\n * ))}\n * </select>\n * );\n */\nexport function useActiveCompany(): ActiveCompany {\n const { profile, activeCompanyId, setActiveCompany } = useAuthRbac();\n\n return useMemo(() => {\n const memberships = profile?.memberships ?? [];\n const membership =\n memberships.find((m) => m.company_id === activeCompanyId) ?? null;\n return {\n id: activeCompanyId,\n membership,\n memberships,\n setActive: setActiveCompany,\n };\n }, [profile, activeCompanyId, setActiveCompany]);\n}\n","import { useMemo } from \"react\";\n\nimport type { FrontendConfig } from \"../types.js\";\n\nimport { useAuthRbac } from \"./AuthRbacProvider.js\";\n\n/**\n * Reads the merged `frontend_config` for the user. Sources in\n * priority order: active company's membership > system roles. Use\n * this to drive sidebar items, dashboard defaults, and any other\n * \"what should this role see\" UX without hardcoded role checks.\n *\n * The shape is intentionally `Record<string, unknown>` — your host\n * project owns the schema. Document your keys (e.g. `sidebar`,\n * `default_dashboard`) once and stick to them.\n */\nexport function useFrontendConfig(): FrontendConfig {\n const { profile, activeCompanyId } = useAuthRbac();\n return useMemo(() => {\n if (!profile) {\n return {};\n }\n const membershipConfig =\n profile.memberships.find((m) => m.company_id === activeCompanyId)\n ?.frontend_config ?? {};\n return { ...profile.system_frontend_config, ...membershipConfig };\n }, [profile, activeCompanyId]);\n}\n","/**\n * Typed factory — turns a const-asserted resource registry into a\n * set of hooks/components whose `resource` arg is constrained to\n * the registered names. Typos become TypeScript errors instead of\n * silent runtime `false`.\n *\n * @example\n * // src/auth/resources.ts\n * import { defineAuthRbac } from \"snipe-auth-rbac/react\";\n *\n * export const RESOURCES = [\n * { resource: \"properties\", scope: \"company\", label: \"Liegenschaften\", group: \"Stammdaten\" },\n * { resource: \"payments\", scope: \"company\", label: \"Zahlungen\", group: \"Finanzen\" },\n * { resource: \"system_audit\", scope: \"system\", label: \"Audit-Log\", group: \"Plattform\" },\n * ] as const;\n *\n * export const { useCan, Can, RequirePermission } = defineAuthRbac(RESOURCES);\n *\n * // ----- elsewhere -----\n * useCan(\"properties\", \"update\"); // ✓\n * useCan(\"paymetns\", \"update\"); // ✗ TS error: not assignable to type \"properties\" | \"payments\" | \"system_audit\"\n */\n\nimport type { ComponentType, ReactNode } from \"react\";\n\nimport type {\n Action,\n ResourceDescriptor,\n ResourceRegistry,\n} from \"./types.js\";\n\n/**\n * Drop-in replacement signatures for the three guards, with a\n * narrowed `resource` arg.\n */\nexport interface TypedGuards<R extends string> {\n useCan: (\n resource: R,\n action: Action,\n options?: { companyId?: string | null },\n ) => boolean;\n Can: ComponentType<{\n resource: R;\n action: Action;\n companyId?: string | null;\n children: ReactNode;\n fallback?: ReactNode;\n }>;\n RequirePermission: ComponentType<{\n resource: R;\n action: Action;\n companyId?: string | null;\n loadingFallback?: ReactNode;\n deniedFallback?: ReactNode;\n children?: ReactNode;\n }>;\n /** The const-asserted registry, re-exported so call-sites can iterate. */\n resources: ResourceRegistry;\n /** All registered resource names as a union — handy for typing\n * application-side data structures. */\n resourceNames: ReadonlyArray<R>;\n}\n\n/**\n * Factory. Run once at module-init time in your host project; the\n * returned hooks/components are referentially stable.\n */\nexport function defineAuthRbac<\n const Reg extends ReadonlyArray<ResourceDescriptor>,\n>(\n resources: Reg,\n // The runtime guards live in the React entry; we accept them\n // here as plain refs so this module stays React-free at the type\n // level. The `react` entry calls this factory passing its own\n // exports so adopters never see the wiring.\n runtime: {\n useCan: TypedGuards<string>[\"useCan\"];\n Can: TypedGuards<string>[\"Can\"];\n RequirePermission: TypedGuards<string>[\"RequirePermission\"];\n },\n): TypedGuards<Reg[number][\"resource\"]> {\n type R = Reg[number][\"resource\"];\n return {\n useCan: runtime.useCan as TypedGuards<R>[\"useCan\"],\n Can: runtime.Can as TypedGuards<R>[\"Can\"],\n RequirePermission: runtime.RequirePermission as TypedGuards<R>[\"RequirePermission\"],\n resources,\n resourceNames: resources.map((r) => r.resource) as ReadonlyArray<R>,\n };\n}\n","/**\n * React + Next.js entry. Import this in browser code:\n *\n * import { AuthRbacProvider, useCan } from \"snipe-auth-rbac/react\";\n *\n * The non-React entry (`snipe-auth-rbac`) re-exports types and the\n * pure resolver, suitable for Node, edge workers, and tests.\n */\n\nexport {\n AuthRbacProvider,\n useAuthRbac,\n type AuthRbacProviderProps,\n} from \"./AuthRbacProvider.js\";\n\nexport { useCan } from \"./useCan.js\";\nexport { Can, type CanProps } from \"./Can.js\";\nexport {\n RequirePermission,\n type RequirePermissionProps,\n} from \"./RequirePermission.js\";\nexport { useActiveCompany, type ActiveCompany } from \"./useActiveCompany.js\";\nexport { useFrontendConfig } from \"./useFrontendConfig.js\";\n\n// Re-exports for convenience so consumers don't need two imports.\nexport type {\n Action,\n AuthRbacFetcher,\n CompanyMembership,\n FrontendConfig,\n PermissionGrid,\n PermissionMap,\n ResourceDescriptor,\n ResourceRegistry,\n ResourceScope,\n RoleSummary,\n UserProfile,\n} from \"../types.js\";\n\nexport {\n createSupabaseFetcher,\n createHttpFetcher,\n detectRbacSchema,\n} from \"../fetchers.js\";\n\nimport { defineAuthRbac as _defineAuthRbac } from \"../define.js\";\nimport { Can } from \"./Can.js\";\nimport { RequirePermission } from \"./RequirePermission.js\";\nimport { useCan } from \"./useCan.js\";\n\nimport type { ResourceDescriptor } from \"../types.js\";\nimport type { TypedGuards } from \"../define.js\";\n\n/**\n * Typed factory — pass a const-asserted resource registry and get\n * back guards whose `resource` arg is constrained to the registered\n * names. Recommended at the top of every host project.\n *\n * See ../define.ts for the full doc + example.\n */\nexport function defineAuthRbac<\n const Reg extends ReadonlyArray<ResourceDescriptor>,\n>(resources: Reg): TypedGuards<Reg[number][\"resource\"]> {\n return _defineAuthRbac(resources, {\n useCan,\n Can,\n RequirePermission,\n });\n}\n\nexport type { TypedGuards } from \"../define.js\";\n"],"mappings":"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","names":["jsx","Fragment","jsx","useMemo","useMemo","useMemo","useMemo","defineAuthRbac"]}
|
|
1
|
+
{"version":3,"sources":["../../src/react/AuthRbacProvider.tsx","../../src/react/useCan.ts","../../src/react/useCanAccessSection.ts","../../src/react/Can.tsx","../../src/react/RequirePermission.tsx","../../src/react/useActiveCompany.ts","../../src/react/useFrontendConfig.ts","../../src/react/index.ts"],"sourcesContent":["import {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from \"react\";\n\nimport {\n buildPermissionResolver,\n type AuthRbacClient,\n} from \"../client.js\";\nimport type {\n AuthRbacFetcher,\n PermissionMap,\n ResourceRegistry,\n UserProfile,\n} from \"../types.js\";\n\ninterface AuthRbacContextValue {\n /**\n * `null` means we haven't hydrated yet. Components should treat\n * the not-loaded state as \"no permissions\" (fail-closed).\n */\n profile: UserProfile | null;\n loading: boolean;\n error: Error | null;\n resources: ResourceRegistry;\n activeCompanyId: string | null;\n setActiveCompany: (id: string | null) => void;\n refresh: () => Promise<void>;\n resolver: AuthRbacClient;\n}\n\nconst AuthRbacContext = createContext<AuthRbacContextValue | null>(null);\n\nexport interface AuthRbacProviderProps {\n fetcher: AuthRbacFetcher;\n resources: ResourceRegistry;\n /**\n * Initial active company. Common patterns:\n * - read from URL query/path\n * - read from localStorage\n * - omit and let the user pick from the switcher\n */\n initialCompanyId?: string | null;\n /**\n * Persistence hook. Called every time the active company changes.\n * Default: writes to `localStorage` under\n * `auth-rbac:active-company`. Pass `false` to disable.\n */\n persistActiveCompany?:\n | ((id: string | null) => void)\n | false;\n children: ReactNode;\n}\n\nconst STORAGE_KEY = \"auth-rbac:active-company\";\n\nconst defaultPersist = (id: string | null) => {\n if (typeof window === \"undefined\") {\n return;\n }\n try {\n if (id == null) {\n window.localStorage.removeItem(STORAGE_KEY);\n } else {\n window.localStorage.setItem(STORAGE_KEY, id);\n }\n } catch {\n // localStorage may be unavailable (private browsing, SSR) —\n // fall back to in-memory only.\n }\n};\n\nconst readPersisted = (): string | null => {\n if (typeof window === \"undefined\") {\n return null;\n }\n try {\n return window.localStorage.getItem(STORAGE_KEY);\n } catch {\n return null;\n }\n};\n\nexport function AuthRbacProvider(props: AuthRbacProviderProps) {\n const { fetcher, resources, initialCompanyId, persistActiveCompany } = props;\n\n const [profile, setProfile] = useState<UserProfile | null>(null);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const [activeCompanyId, setActiveCompanyState] = useState<string | null>(\n initialCompanyId ?? readPersisted(),\n );\n\n const persist = useMemo(() => {\n if (persistActiveCompany === false) {\n return () => {};\n }\n return persistActiveCompany ?? defaultPersist;\n }, [persistActiveCompany]);\n\n const setActiveCompany = useCallback(\n (id: string | null) => {\n setActiveCompanyState(id);\n persist(id);\n },\n [persist],\n );\n\n const refresh = useCallback(async () => {\n setLoading(true);\n setError(null);\n try {\n const next = await fetcher.fetchProfile();\n setProfile(next);\n // If the persisted company isn't a membership, fall back to\n // the first one (or null for users with no memberships).\n const stillMember =\n activeCompanyId != null &&\n next.memberships.some((m) => m.company_id === activeCompanyId);\n if (!stillMember) {\n const fallback =\n next.memberships[0]?.company_id ?? null;\n setActiveCompanyState(fallback);\n persist(fallback);\n }\n } catch (e) {\n setError(e instanceof Error ? e : new Error(String(e)));\n } finally {\n setLoading(false);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [fetcher]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n const resolver = useMemo<AuthRbacClient>(() => {\n if (profile == null) {\n // Empty resolver until the profile lands. Always returns\n // false → guards fall through to the unauthenticated branch.\n return {\n can: () => false,\n canAccessSection: () => false,\n activePermissions: () => ({}) as PermissionMap,\n systemPermissions: () => ({}) as PermissionMap,\n };\n }\n return buildPermissionResolver(resources, profile, activeCompanyId);\n }, [profile, resources, activeCompanyId]);\n\n const value = useMemo<AuthRbacContextValue>(\n () => ({\n profile,\n loading,\n error,\n resources,\n activeCompanyId,\n setActiveCompany,\n refresh,\n resolver,\n }),\n [\n profile,\n loading,\n error,\n resources,\n activeCompanyId,\n setActiveCompany,\n refresh,\n resolver,\n ],\n );\n\n return (\n <AuthRbacContext.Provider value={value}>\n {props.children}\n </AuthRbacContext.Provider>\n );\n}\n\nexport function useAuthRbac(): AuthRbacContextValue {\n const ctx = useContext(AuthRbacContext);\n if (!ctx) {\n throw new Error(\n \"useAuthRbac must be used within an <AuthRbacProvider> — wrap your app at the root.\",\n );\n }\n return ctx;\n}\n","import type { Action } from \"../types.js\";\nimport type { CanOptions } from \"../client.js\";\n\nimport { useAuthRbac } from \"./AuthRbacProvider.js\";\n\n/**\n * Boolean permission check.\n *\n * @example\n * const canEdit = useCan(\"properties\", \"update\");\n * <Button disabled={!canEdit}>Speichern</Button>\n *\n * @example explicitly target a non-active company\n * const canRead = useCan(\"payments\", \"read\", { companyId: targetId });\n */\nexport function useCan(\n resource: string,\n action: Action,\n options?: CanOptions,\n): boolean {\n const { resolver } = useAuthRbac();\n return resolver.can(resource, action, options);\n}\n","import type { Action } from \"../types.js\";\nimport type { CanOptions } from \"../client.js\";\n\nimport { useAuthRbac } from \"./AuthRbacProvider.js\";\n\n/**\n * Direct-grant check, for sidebar items and list-page route guards.\n *\n * Returns `true` only when the action is granted on the resource as\n * a **direct** admin grant (no `<action>_granted_via`). Rows that\n * exist only because a parent resource's `dependsOn` cascade\n * materialised them return `false` here — even though\n * `useCan(resource, action)` would return `true` for the same role.\n *\n * Use case: a Verwalter with only `leases:read` direct should see\n * Mietverträge in the sidebar but **not** Mieter / Einheiten /\n * Liegenschaften — those reads are implied so the lease detail page\n * can render, not because the role should navigate to them as\n * top-level sections.\n *\n * @example sidebar item filtering\n * const showLeasesInSidebar = useCanAccessSection(\"leases\");\n * const showUnitsInSidebar = useCanAccessSection(\"units\");\n *\n * @example list-route gating\n * if (!useCanAccessSection(\"units\")) return <Forbidden />;\n *\n * Available since 0.4.0. With older SQL that doesn't return\n * `direct_*` maps, this always answers `false` — adopters still on\n * pre-0.4.0 SQL should keep using `useCan`.\n */\nexport function useCanAccessSection(\n resource: string,\n action: Action = \"read\",\n options?: CanOptions,\n): boolean {\n const { resolver } = useAuthRbac();\n return resolver.canAccessSection(resource, action, options);\n}\n","import type { ReactNode } from \"react\";\n\nimport type { Action } from \"../types.js\";\nimport type { CanOptions } from \"../client.js\";\n\nimport { useCan } from \"./useCan.js\";\n\nexport interface CanProps extends CanOptions {\n resource: string;\n action: Action;\n /** Rendered when the user has the permission. */\n children: ReactNode;\n /**\n * Rendered when the user does NOT have the permission. Defaults\n * to `null` (silent hide). Pass a `<NoPermissionView />` or a\n * tooltip-wrapper to surface the denial explicitly.\n */\n fallback?: ReactNode;\n}\n\n/**\n * Subtree gate. Bails before children render so any data fetching\n * inside `children` is skipped for users without permission.\n */\nexport function Can(props: CanProps) {\n const { resource, action, companyId, children, fallback = null } = props;\n const allowed = useCan(resource, action, { companyId });\n return <>{allowed ? children : fallback}</>;\n}\n","import type { ReactNode } from \"react\";\n\nimport type { Action } from \"../types.js\";\nimport type { CanOptions } from \"../client.js\";\n\nimport { useAuthRbac } from \"./AuthRbacProvider.js\";\nimport { useCan } from \"./useCan.js\";\n\nexport interface RequirePermissionProps extends CanOptions {\n resource: string;\n action: Action;\n /**\n * What to render while the profile is still loading. Defaults to\n * `null` (no flash) — pass a spinner if your routes typically\n * mount before the profile lands.\n */\n loadingFallback?: ReactNode;\n /**\n * What to render when access is denied. Defaults to a minimal\n * \"Sie haben keinen Zugriff\" message; pass your own component to\n * theme it.\n */\n deniedFallback?: ReactNode;\n /**\n * For `react-router-dom v6` route-element usage, pass an `<Outlet />`\n * here — the gate resolves to either the outlet or the denied\n * fallback. For component-tree usage, pass any children.\n */\n children?: ReactNode;\n}\n\n/**\n * Route- or component-level guard. Three render branches:\n *\n * - profile not yet loaded → `loadingFallback`\n * - permission denied → `deniedFallback`\n * - permission granted → `children`\n *\n * Drop-in replacement for the legacy `<RequireRolesRoute>` pattern.\n *\n * @example\n * // App.tsx route table\n * <Route element={\n * <RequirePermission resource=\"payments\" action=\"read\">\n * <Outlet />\n * </RequirePermission>\n * }>\n * <Route path=\"/payments\" element={<PaymentsPage />} />\n * </Route>\n */\nexport function RequirePermission(props: RequirePermissionProps) {\n const {\n resource,\n action,\n companyId,\n loadingFallback = null,\n deniedFallback = (\n <div role=\"alert\" style={{ padding: 24 }}>\n <strong>Sie haben keinen Zugriff.</strong>\n </div>\n ),\n children = null,\n } = props;\n\n const { profile, loading } = useAuthRbac();\n const allowed = useCan(resource, action, { companyId });\n\n if (loading || profile == null) {\n return <>{loadingFallback}</>;\n }\n if (!allowed) {\n return <>{deniedFallback}</>;\n }\n return <>{children}</>;\n}\n","import { useMemo } from \"react\";\n\nimport type { CompanyMembership } from \"../types.js\";\n\nimport { useAuthRbac } from \"./AuthRbacProvider.js\";\n\nexport interface ActiveCompany {\n id: string | null;\n membership: CompanyMembership | null;\n memberships: CompanyMembership[];\n setActive: (id: string | null) => void;\n}\n\n/**\n * Read + switch the active company.\n *\n * @example\n * const { id, memberships, setActive } = useActiveCompany();\n *\n * return (\n * <select value={id ?? \"\"} onChange={(e) => setActive(e.target.value || null)}>\n * {memberships.map((m) => (\n * <option key={m.company_id} value={m.company_id}>{m.company_name}</option>\n * ))}\n * </select>\n * );\n */\nexport function useActiveCompany(): ActiveCompany {\n const { profile, activeCompanyId, setActiveCompany } = useAuthRbac();\n\n return useMemo(() => {\n const memberships = profile?.memberships ?? [];\n const membership =\n memberships.find((m) => m.company_id === activeCompanyId) ?? null;\n return {\n id: activeCompanyId,\n membership,\n memberships,\n setActive: setActiveCompany,\n };\n }, [profile, activeCompanyId, setActiveCompany]);\n}\n","import { useMemo } from \"react\";\n\nimport type { FrontendConfig } from \"../types.js\";\n\nimport { useAuthRbac } from \"./AuthRbacProvider.js\";\n\n/**\n * Reads the merged `frontend_config` for the user. Sources in\n * priority order: active company's membership > system roles. Use\n * this to drive sidebar items, dashboard defaults, and any other\n * \"what should this role see\" UX without hardcoded role checks.\n *\n * The shape is intentionally `Record<string, unknown>` — your host\n * project owns the schema. Document your keys (e.g. `sidebar`,\n * `default_dashboard`) once and stick to them.\n */\nexport function useFrontendConfig(): FrontendConfig {\n const { profile, activeCompanyId } = useAuthRbac();\n return useMemo(() => {\n if (!profile) {\n return {};\n }\n const membershipConfig =\n profile.memberships.find((m) => m.company_id === activeCompanyId)\n ?.frontend_config ?? {};\n return { ...profile.system_frontend_config, ...membershipConfig };\n }, [profile, activeCompanyId]);\n}\n","/**\n * React + Next.js entry. Import this in browser code:\n *\n * import { AuthRbacProvider, useCan } from \"snipe-auth-rbac/react\";\n *\n * The non-React entry (`snipe-auth-rbac`) re-exports types and the\n * pure resolver, suitable for Node, edge workers, and tests.\n */\n\nexport {\n AuthRbacProvider,\n useAuthRbac,\n type AuthRbacProviderProps,\n} from \"./AuthRbacProvider.js\";\n\nexport { useCan } from \"./useCan.js\";\nexport { useCanAccessSection } from \"./useCanAccessSection.js\";\nexport { Can, type CanProps } from \"./Can.js\";\nexport {\n RequirePermission,\n type RequirePermissionProps,\n} from \"./RequirePermission.js\";\nexport { useActiveCompany, type ActiveCompany } from \"./useActiveCompany.js\";\nexport { useFrontendConfig } from \"./useFrontendConfig.js\";\n\n// Re-exports for convenience so consumers don't need two imports.\nexport type {\n Action,\n AuthRbacFetcher,\n CompanyMembership,\n DependencyEdge,\n DirectGrantMap,\n FrontendConfig,\n PermissionGrid,\n PermissionMap,\n ResourceDescriptor,\n ResourceRegistry,\n ResourceScope,\n RoleSummary,\n UserProfile,\n} from \"../types.js\";\n\nexport { RbacRegistryError } from \"../define.js\";\n\nexport {\n createSupabaseFetcher,\n createHttpFetcher,\n detectRbacSchema,\n} from \"../fetchers.js\";\n\nimport { defineAuthRbac as _defineAuthRbac } from \"../define.js\";\nimport { Can } from \"./Can.js\";\nimport { RequirePermission } from \"./RequirePermission.js\";\nimport { useCan } from \"./useCan.js\";\nimport { useCanAccessSection } from \"./useCanAccessSection.js\";\n\nimport type { ResourceDescriptor } from \"../types.js\";\nimport type { TypedGuards } from \"../define.js\";\n\n/**\n * Typed factory — pass a const-asserted resource registry and get\n * back guards whose `resource` arg is constrained to the registered\n * names. Recommended at the top of every host project.\n *\n * See ../define.ts for the full doc + example.\n */\nexport function defineAuthRbac<\n const Reg extends ReadonlyArray<ResourceDescriptor>,\n>(resources: Reg): TypedGuards<Reg[number][\"resource\"]> {\n return _defineAuthRbac(resources, {\n useCan,\n useCanAccessSection,\n Can,\n RequirePermission,\n });\n}\n\nexport type { TypedGuards } from \"../define.js\";\n"],"mappings":"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","names":["jsx","Fragment","jsx","useMemo","useMemo","useMemo","useMemo","defineAuthRbac"]}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types — used by both the transport-agnostic client and the
|
|
3
|
+
* React layer. Adopters that don't use React only need to depend on
|
|
4
|
+
* the `index` entry; the `react` entry's types extend these.
|
|
5
|
+
*/
|
|
6
|
+
type Action = "read" | "write" | "update" | "delete";
|
|
7
|
+
type ResourceScope = "system" | "company";
|
|
8
|
+
/**
|
|
9
|
+
* One edge in a resource's `dependsOn` graph. When the parent
|
|
10
|
+
* resource has `<action>` granted to a role in the admin matrix UI,
|
|
11
|
+
* the matrix materialises a row on the child carrying the same
|
|
12
|
+
* action, with the parent's name recorded in `<action>_granted_via`
|
|
13
|
+
* on `rbac.role_permissions`.
|
|
14
|
+
*
|
|
15
|
+
* `actions` defaults to `['read']` when omitted. The shorthand
|
|
16
|
+
* `dependsOn: ['tenants']` is equivalent to
|
|
17
|
+
* `dependsOn: [{ resource: 'tenants', actions: ['read'] }]`.
|
|
18
|
+
*
|
|
19
|
+
* Available since 0.4.0.
|
|
20
|
+
*/
|
|
21
|
+
interface DependencyEdge {
|
|
22
|
+
resource: string;
|
|
23
|
+
actions?: ReadonlyArray<Action>;
|
|
24
|
+
}
|
|
25
|
+
interface ResourceDescriptor {
|
|
26
|
+
resource: string;
|
|
27
|
+
scope: ResourceScope;
|
|
28
|
+
label: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
group?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Per-edge action cascade. Granting `<this>:<action>` in the admin
|
|
33
|
+
* matrix automatically grants `<edge.resource>:<action>` for every
|
|
34
|
+
* edge whose `actions` array includes `<action>`. Useful for
|
|
35
|
+
* "granting `leases:read` should also let me read the tenant +
|
|
36
|
+
* unit + property that the lease joins to" — declare those edges
|
|
37
|
+
* here and the matrix UI handles the rest.
|
|
38
|
+
*
|
|
39
|
+
* Implied rows are real rows on `rbac.role_permissions` and look
|
|
40
|
+
* identical to direct grants at the resolver / RLS layer. The
|
|
41
|
+
* `<action>_granted_via` column distinguishes them, which lets
|
|
42
|
+
* `canAccessSection(resource)` answer "is this a direct grant?"
|
|
43
|
+
* for top-level navigation gating.
|
|
44
|
+
*
|
|
45
|
+
* Only edges whose `actions` include the toggled action cascade.
|
|
46
|
+
* Read is the default — declare `actions` explicitly if you want
|
|
47
|
+
* the cascade to apply to write/update/delete as well.
|
|
48
|
+
*
|
|
49
|
+
* `defineAuthRbac()` rejects cyclic dependency graphs at boot.
|
|
50
|
+
*
|
|
51
|
+
* Available since 0.4.0.
|
|
52
|
+
*/
|
|
53
|
+
dependsOn?: ReadonlyArray<string | DependencyEdge>;
|
|
54
|
+
}
|
|
55
|
+
interface RoleSummary {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
is_system?: boolean;
|
|
59
|
+
is_super?: boolean;
|
|
60
|
+
frontend_config?: FrontendConfig;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Free-form per-role config that the host project can interpret as
|
|
64
|
+
* it sees fit. A typical shape includes `sidebar` (list of section
|
|
65
|
+
* keys), `default_dashboard` (string), `home_route` (string).
|
|
66
|
+
*/
|
|
67
|
+
type FrontendConfig = Record<string, unknown>;
|
|
68
|
+
interface PermissionGrid {
|
|
69
|
+
read: boolean;
|
|
70
|
+
write: boolean;
|
|
71
|
+
update: boolean;
|
|
72
|
+
delete: boolean;
|
|
73
|
+
}
|
|
74
|
+
type PermissionMap = Record<string, PermissionGrid>;
|
|
75
|
+
/**
|
|
76
|
+
* Map of `resource → true` for resources whose given action is
|
|
77
|
+
* granted **directly** to a role (i.e. with `<action>_granted_via`
|
|
78
|
+
* NULL on `rbac.role_permissions`). Implied rows do not appear here.
|
|
79
|
+
*
|
|
80
|
+
* Used to distinguish list-page access (direct grant required) from
|
|
81
|
+
* detail-page access (direct OR implied) — see `canAccessSection`.
|
|
82
|
+
*
|
|
83
|
+
* Available since 0.4.0. Returned by `rbac.user_profile()`; older
|
|
84
|
+
* SQL versions omit these fields, in which case the client treats
|
|
85
|
+
* them as empty objects (back-compat).
|
|
86
|
+
*/
|
|
87
|
+
type DirectGrantMap = Readonly<Record<string, boolean>>;
|
|
88
|
+
interface CompanyMembership {
|
|
89
|
+
company_id: string;
|
|
90
|
+
company_name: string;
|
|
91
|
+
company_slug?: string | null;
|
|
92
|
+
roles: RoleSummary[];
|
|
93
|
+
permissions: PermissionMap;
|
|
94
|
+
/** Merged frontend_config across all roles the user holds in this company. */
|
|
95
|
+
frontend_config: FrontendConfig;
|
|
96
|
+
/** Per-action direct-grant maps for this company. 0.4.0+ */
|
|
97
|
+
direct_reads?: DirectGrantMap;
|
|
98
|
+
direct_writes?: DirectGrantMap;
|
|
99
|
+
direct_updates?: DirectGrantMap;
|
|
100
|
+
direct_deletes?: DirectGrantMap;
|
|
101
|
+
}
|
|
102
|
+
interface UserProfile {
|
|
103
|
+
user_id: string;
|
|
104
|
+
is_super_admin: boolean;
|
|
105
|
+
system_roles: RoleSummary[];
|
|
106
|
+
system_permissions: PermissionMap;
|
|
107
|
+
/** Merged frontend_config across all of the user's system roles. */
|
|
108
|
+
system_frontend_config: FrontendConfig;
|
|
109
|
+
memberships: CompanyMembership[];
|
|
110
|
+
/** Per-action direct-grant maps at system scope. 0.4.0+ */
|
|
111
|
+
system_direct_reads?: DirectGrantMap;
|
|
112
|
+
system_direct_writes?: DirectGrantMap;
|
|
113
|
+
system_direct_updates?: DirectGrantMap;
|
|
114
|
+
system_direct_deletes?: DirectGrantMap;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Adopter-supplied transport. Two flavours are supported out of the
|
|
118
|
+
* box:
|
|
119
|
+
*
|
|
120
|
+
* 1. Pass a Supabase client + a JWT-derived `user_id` and the
|
|
121
|
+
* library calls the package's SQL RPC `rbac.user_profile`.
|
|
122
|
+
* 2. Pass a plain `fetcher` (anything that resolves a `UserProfile`)
|
|
123
|
+
* and the library calls that. Use this when your backend serves
|
|
124
|
+
* a custom `/api/users/me/profile` endpoint or when you don't
|
|
125
|
+
* run Supabase at all.
|
|
126
|
+
*/
|
|
127
|
+
interface AuthRbacFetcher {
|
|
128
|
+
fetchProfile(): Promise<UserProfile>;
|
|
129
|
+
}
|
|
130
|
+
type ResourceRegistry = ReadonlyArray<ResourceDescriptor>;
|
|
131
|
+
|
|
132
|
+
export type { Action as A, CompanyMembership as C, DependencyEdge as D, FrontendConfig as F, PermissionGrid as P, ResourceScope as R, UserProfile as U, ResourceDescriptor as a, AuthRbacFetcher as b, ResourceRegistry as c, DirectGrantMap as d, PermissionMap as e, RoleSummary as f };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types — used by both the transport-agnostic client and the
|
|
3
|
+
* React layer. Adopters that don't use React only need to depend on
|
|
4
|
+
* the `index` entry; the `react` entry's types extend these.
|
|
5
|
+
*/
|
|
6
|
+
type Action = "read" | "write" | "update" | "delete";
|
|
7
|
+
type ResourceScope = "system" | "company";
|
|
8
|
+
/**
|
|
9
|
+
* One edge in a resource's `dependsOn` graph. When the parent
|
|
10
|
+
* resource has `<action>` granted to a role in the admin matrix UI,
|
|
11
|
+
* the matrix materialises a row on the child carrying the same
|
|
12
|
+
* action, with the parent's name recorded in `<action>_granted_via`
|
|
13
|
+
* on `rbac.role_permissions`.
|
|
14
|
+
*
|
|
15
|
+
* `actions` defaults to `['read']` when omitted. The shorthand
|
|
16
|
+
* `dependsOn: ['tenants']` is equivalent to
|
|
17
|
+
* `dependsOn: [{ resource: 'tenants', actions: ['read'] }]`.
|
|
18
|
+
*
|
|
19
|
+
* Available since 0.4.0.
|
|
20
|
+
*/
|
|
21
|
+
interface DependencyEdge {
|
|
22
|
+
resource: string;
|
|
23
|
+
actions?: ReadonlyArray<Action>;
|
|
24
|
+
}
|
|
25
|
+
interface ResourceDescriptor {
|
|
26
|
+
resource: string;
|
|
27
|
+
scope: ResourceScope;
|
|
28
|
+
label: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
group?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Per-edge action cascade. Granting `<this>:<action>` in the admin
|
|
33
|
+
* matrix automatically grants `<edge.resource>:<action>` for every
|
|
34
|
+
* edge whose `actions` array includes `<action>`. Useful for
|
|
35
|
+
* "granting `leases:read` should also let me read the tenant +
|
|
36
|
+
* unit + property that the lease joins to" — declare those edges
|
|
37
|
+
* here and the matrix UI handles the rest.
|
|
38
|
+
*
|
|
39
|
+
* Implied rows are real rows on `rbac.role_permissions` and look
|
|
40
|
+
* identical to direct grants at the resolver / RLS layer. The
|
|
41
|
+
* `<action>_granted_via` column distinguishes them, which lets
|
|
42
|
+
* `canAccessSection(resource)` answer "is this a direct grant?"
|
|
43
|
+
* for top-level navigation gating.
|
|
44
|
+
*
|
|
45
|
+
* Only edges whose `actions` include the toggled action cascade.
|
|
46
|
+
* Read is the default — declare `actions` explicitly if you want
|
|
47
|
+
* the cascade to apply to write/update/delete as well.
|
|
48
|
+
*
|
|
49
|
+
* `defineAuthRbac()` rejects cyclic dependency graphs at boot.
|
|
50
|
+
*
|
|
51
|
+
* Available since 0.4.0.
|
|
52
|
+
*/
|
|
53
|
+
dependsOn?: ReadonlyArray<string | DependencyEdge>;
|
|
54
|
+
}
|
|
55
|
+
interface RoleSummary {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
is_system?: boolean;
|
|
59
|
+
is_super?: boolean;
|
|
60
|
+
frontend_config?: FrontendConfig;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Free-form per-role config that the host project can interpret as
|
|
64
|
+
* it sees fit. A typical shape includes `sidebar` (list of section
|
|
65
|
+
* keys), `default_dashboard` (string), `home_route` (string).
|
|
66
|
+
*/
|
|
67
|
+
type FrontendConfig = Record<string, unknown>;
|
|
68
|
+
interface PermissionGrid {
|
|
69
|
+
read: boolean;
|
|
70
|
+
write: boolean;
|
|
71
|
+
update: boolean;
|
|
72
|
+
delete: boolean;
|
|
73
|
+
}
|
|
74
|
+
type PermissionMap = Record<string, PermissionGrid>;
|
|
75
|
+
/**
|
|
76
|
+
* Map of `resource → true` for resources whose given action is
|
|
77
|
+
* granted **directly** to a role (i.e. with `<action>_granted_via`
|
|
78
|
+
* NULL on `rbac.role_permissions`). Implied rows do not appear here.
|
|
79
|
+
*
|
|
80
|
+
* Used to distinguish list-page access (direct grant required) from
|
|
81
|
+
* detail-page access (direct OR implied) — see `canAccessSection`.
|
|
82
|
+
*
|
|
83
|
+
* Available since 0.4.0. Returned by `rbac.user_profile()`; older
|
|
84
|
+
* SQL versions omit these fields, in which case the client treats
|
|
85
|
+
* them as empty objects (back-compat).
|
|
86
|
+
*/
|
|
87
|
+
type DirectGrantMap = Readonly<Record<string, boolean>>;
|
|
88
|
+
interface CompanyMembership {
|
|
89
|
+
company_id: string;
|
|
90
|
+
company_name: string;
|
|
91
|
+
company_slug?: string | null;
|
|
92
|
+
roles: RoleSummary[];
|
|
93
|
+
permissions: PermissionMap;
|
|
94
|
+
/** Merged frontend_config across all roles the user holds in this company. */
|
|
95
|
+
frontend_config: FrontendConfig;
|
|
96
|
+
/** Per-action direct-grant maps for this company. 0.4.0+ */
|
|
97
|
+
direct_reads?: DirectGrantMap;
|
|
98
|
+
direct_writes?: DirectGrantMap;
|
|
99
|
+
direct_updates?: DirectGrantMap;
|
|
100
|
+
direct_deletes?: DirectGrantMap;
|
|
101
|
+
}
|
|
102
|
+
interface UserProfile {
|
|
103
|
+
user_id: string;
|
|
104
|
+
is_super_admin: boolean;
|
|
105
|
+
system_roles: RoleSummary[];
|
|
106
|
+
system_permissions: PermissionMap;
|
|
107
|
+
/** Merged frontend_config across all of the user's system roles. */
|
|
108
|
+
system_frontend_config: FrontendConfig;
|
|
109
|
+
memberships: CompanyMembership[];
|
|
110
|
+
/** Per-action direct-grant maps at system scope. 0.4.0+ */
|
|
111
|
+
system_direct_reads?: DirectGrantMap;
|
|
112
|
+
system_direct_writes?: DirectGrantMap;
|
|
113
|
+
system_direct_updates?: DirectGrantMap;
|
|
114
|
+
system_direct_deletes?: DirectGrantMap;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Adopter-supplied transport. Two flavours are supported out of the
|
|
118
|
+
* box:
|
|
119
|
+
*
|
|
120
|
+
* 1. Pass a Supabase client + a JWT-derived `user_id` and the
|
|
121
|
+
* library calls the package's SQL RPC `rbac.user_profile`.
|
|
122
|
+
* 2. Pass a plain `fetcher` (anything that resolves a `UserProfile`)
|
|
123
|
+
* and the library calls that. Use this when your backend serves
|
|
124
|
+
* a custom `/api/users/me/profile` endpoint or when you don't
|
|
125
|
+
* run Supabase at all.
|
|
126
|
+
*/
|
|
127
|
+
interface AuthRbacFetcher {
|
|
128
|
+
fetchProfile(): Promise<UserProfile>;
|
|
129
|
+
}
|
|
130
|
+
type ResourceRegistry = ReadonlyArray<ResourceDescriptor>;
|
|
131
|
+
|
|
132
|
+
export type { Action as A, CompanyMembership as C, DependencyEdge as D, FrontendConfig as F, PermissionGrid as P, ResourceScope as R, UserProfile as U, ResourceDescriptor as a, AuthRbacFetcher as b, ResourceRegistry as c, DirectGrantMap as d, PermissionMap as e, RoleSummary as f };
|
package/package.json
CHANGED
package/sql/0001_initial.sql
CHANGED
|
@@ -138,9 +138,44 @@ CREATE TABLE IF NOT EXISTS rbac.role_permissions (
|
|
|
138
138
|
can_write boolean NOT NULL DEFAULT false,
|
|
139
139
|
can_update boolean NOT NULL DEFAULT false,
|
|
140
140
|
can_delete boolean NOT NULL DEFAULT false,
|
|
141
|
+
-- 0.4.0+. Per-action origin tracking. NULL = direct admin grant;
|
|
142
|
+
-- non-NULL = name of the parent resource whose `dependsOn` edge
|
|
143
|
+
-- caused this cell to be materialised. The matrix UI reads this
|
|
144
|
+
-- to render the "Implied by …" badge; the resolver and
|
|
145
|
+
-- `rbac.user_can(...)` SQL function ignore the columns entirely
|
|
146
|
+
-- (flat matrix lookups are unchanged so RLS stays performant).
|
|
147
|
+
read_granted_via text,
|
|
148
|
+
write_granted_via text,
|
|
149
|
+
update_granted_via text,
|
|
150
|
+
delete_granted_via text,
|
|
141
151
|
PRIMARY KEY (role_id, resource)
|
|
142
152
|
);
|
|
143
153
|
|
|
154
|
+
-- Backfill for adopters upgrading from 0.3.x.
|
|
155
|
+
ALTER TABLE rbac.role_permissions
|
|
156
|
+
ADD COLUMN IF NOT EXISTS read_granted_via text,
|
|
157
|
+
ADD COLUMN IF NOT EXISTS write_granted_via text,
|
|
158
|
+
ADD COLUMN IF NOT EXISTS update_granted_via text,
|
|
159
|
+
ADD COLUMN IF NOT EXISTS delete_granted_via text;
|
|
160
|
+
|
|
161
|
+
-- 0.4.0+. Dependent-read graph. One row per (parent, child, action)
|
|
162
|
+
-- cascade edge. The host registry's `dependsOn` arrays get
|
|
163
|
+
-- materialised here by `service.syncResources()` on app boot. The
|
|
164
|
+
-- admin matrix UI consults this on every toggle-on to decide which
|
|
165
|
+
-- implied rows to write alongside the parent.
|
|
166
|
+
--
|
|
167
|
+
-- The resolver does NOT read this table at runtime — implied rows
|
|
168
|
+
-- live on `rbac.role_permissions` and look identical to direct
|
|
169
|
+
-- grants at the policy layer. This keeps RLS flat and fast.
|
|
170
|
+
CREATE TABLE IF NOT EXISTS rbac.resource_dependencies (
|
|
171
|
+
parent_resource text NOT NULL REFERENCES rbac.resources(resource) ON DELETE CASCADE,
|
|
172
|
+
child_resource text NOT NULL REFERENCES rbac.resources(resource) ON DELETE CASCADE,
|
|
173
|
+
action text NOT NULL CHECK (action IN ('read','write','update','delete')),
|
|
174
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
175
|
+
PRIMARY KEY (parent_resource, child_resource, action),
|
|
176
|
+
CONSTRAINT resource_dependencies_no_self CHECK (parent_resource <> child_resource)
|
|
177
|
+
);
|
|
178
|
+
|
|
144
179
|
CREATE OR REPLACE FUNCTION rbac.check_role_resource_scope()
|
|
145
180
|
RETURNS trigger
|
|
146
181
|
LANGUAGE plpgsql
|
|
@@ -289,12 +324,20 @@ AS $$
|
|
|
289
324
|
JOIN rbac.roles r ON r.id = usr.role_id
|
|
290
325
|
WHERE usr.user_id = p_user_id
|
|
291
326
|
),
|
|
327
|
+
-- 0.4.0+. Per-action direct grants are aggregated alongside the
|
|
328
|
+
-- regular permission booleans. Direct = action true AND its
|
|
329
|
+
-- granted_via is NULL on at least one of the user's roles. bool_or
|
|
330
|
+
-- across roles gives "any role grants this directly".
|
|
292
331
|
system_perms AS (
|
|
293
332
|
SELECT rp.resource,
|
|
294
333
|
bool_or(rp.can_read) AS can_read,
|
|
295
334
|
bool_or(rp.can_write) AS can_write,
|
|
296
335
|
bool_or(rp.can_update) AS can_update,
|
|
297
|
-
bool_or(rp.can_delete) AS can_delete
|
|
336
|
+
bool_or(rp.can_delete) AS can_delete,
|
|
337
|
+
bool_or(rp.can_read AND rp.read_granted_via IS NULL) AS direct_read,
|
|
338
|
+
bool_or(rp.can_write AND rp.write_granted_via IS NULL) AS direct_write,
|
|
339
|
+
bool_or(rp.can_update AND rp.update_granted_via IS NULL) AS direct_update,
|
|
340
|
+
bool_or(rp.can_delete AND rp.delete_granted_via IS NULL) AS direct_delete
|
|
298
341
|
FROM rbac.user_system_roles usr
|
|
299
342
|
JOIN rbac.role_permissions rp ON rp.role_id = usr.role_id
|
|
300
343
|
WHERE usr.user_id = p_user_id
|
|
@@ -320,7 +363,11 @@ AS $$
|
|
|
320
363
|
bool_or(rp.can_read) AS can_read,
|
|
321
364
|
bool_or(rp.can_write) AS can_write,
|
|
322
365
|
bool_or(rp.can_update) AS can_update,
|
|
323
|
-
bool_or(rp.can_delete) AS can_delete
|
|
366
|
+
bool_or(rp.can_delete) AS can_delete,
|
|
367
|
+
bool_or(rp.can_read AND rp.read_granted_via IS NULL) AS direct_read,
|
|
368
|
+
bool_or(rp.can_write AND rp.write_granted_via IS NULL) AS direct_write,
|
|
369
|
+
bool_or(rp.can_update AND rp.update_granted_via IS NULL) AS direct_update,
|
|
370
|
+
bool_or(rp.can_delete AND rp.delete_granted_via IS NULL) AS direct_delete
|
|
324
371
|
FROM rbac.user_company_roles ucr
|
|
325
372
|
JOIN rbac.role_permissions rp ON rp.role_id = ucr.role_id
|
|
326
373
|
WHERE ucr.user_id = p_user_id
|
|
@@ -340,6 +387,24 @@ AS $$
|
|
|
340
387
|
)) FROM system_perms),
|
|
341
388
|
'{}'::jsonb
|
|
342
389
|
),
|
|
390
|
+
-- 0.4.0+. Per-action direct-grant maps. Drives canAccessSection()
|
|
391
|
+
-- on the client side for top-level nav / list-page gating.
|
|
392
|
+
'system_direct_reads', coalesce(
|
|
393
|
+
(SELECT jsonb_object_agg(resource, true)
|
|
394
|
+
FROM system_perms WHERE direct_read), '{}'::jsonb
|
|
395
|
+
),
|
|
396
|
+
'system_direct_writes', coalesce(
|
|
397
|
+
(SELECT jsonb_object_agg(resource, true)
|
|
398
|
+
FROM system_perms WHERE direct_write), '{}'::jsonb
|
|
399
|
+
),
|
|
400
|
+
'system_direct_updates', coalesce(
|
|
401
|
+
(SELECT jsonb_object_agg(resource, true)
|
|
402
|
+
FROM system_perms WHERE direct_update), '{}'::jsonb
|
|
403
|
+
),
|
|
404
|
+
'system_direct_deletes', coalesce(
|
|
405
|
+
(SELECT jsonb_object_agg(resource, true)
|
|
406
|
+
FROM system_perms WHERE direct_delete), '{}'::jsonb
|
|
407
|
+
),
|
|
343
408
|
'memberships', coalesce(
|
|
344
409
|
(SELECT jsonb_agg(jsonb_build_object(
|
|
345
410
|
'company_id', m.company_id,
|
|
@@ -354,6 +419,30 @@ AS $$
|
|
|
354
419
|
FROM company_perms cp
|
|
355
420
|
WHERE cp.company_id = m.company_id),
|
|
356
421
|
'{}'::jsonb
|
|
422
|
+
),
|
|
423
|
+
'direct_reads', coalesce(
|
|
424
|
+
(SELECT jsonb_object_agg(cp.resource, true)
|
|
425
|
+
FROM company_perms cp
|
|
426
|
+
WHERE cp.company_id = m.company_id AND cp.direct_read),
|
|
427
|
+
'{}'::jsonb
|
|
428
|
+
),
|
|
429
|
+
'direct_writes', coalesce(
|
|
430
|
+
(SELECT jsonb_object_agg(cp.resource, true)
|
|
431
|
+
FROM company_perms cp
|
|
432
|
+
WHERE cp.company_id = m.company_id AND cp.direct_write),
|
|
433
|
+
'{}'::jsonb
|
|
434
|
+
),
|
|
435
|
+
'direct_updates', coalesce(
|
|
436
|
+
(SELECT jsonb_object_agg(cp.resource, true)
|
|
437
|
+
FROM company_perms cp
|
|
438
|
+
WHERE cp.company_id = m.company_id AND cp.direct_update),
|
|
439
|
+
'{}'::jsonb
|
|
440
|
+
),
|
|
441
|
+
'direct_deletes', coalesce(
|
|
442
|
+
(SELECT jsonb_object_agg(cp.resource, true)
|
|
443
|
+
FROM company_perms cp
|
|
444
|
+
WHERE cp.company_id = m.company_id AND cp.direct_delete),
|
|
445
|
+
'{}'::jsonb
|
|
357
446
|
)
|
|
358
447
|
)) FROM memberships m),
|
|
359
448
|
'[]'::jsonb
|
|
@@ -361,6 +450,52 @@ AS $$
|
|
|
361
450
|
);
|
|
362
451
|
$$;
|
|
363
452
|
|
|
453
|
+
-- 0.4.0+. Matrix-UI cascade helper. Returns the (child, action) pairs
|
|
454
|
+
-- that should be materialised when an admin toggles
|
|
455
|
+
-- (parent_resource, p_action) on for a role.
|
|
456
|
+
CREATE OR REPLACE FUNCTION rbac.list_implied_grants(
|
|
457
|
+
p_parent_resource text,
|
|
458
|
+
p_action text
|
|
459
|
+
) RETURNS TABLE (child_resource text, action text)
|
|
460
|
+
LANGUAGE sql
|
|
461
|
+
STABLE
|
|
462
|
+
SET search_path = rbac, public
|
|
463
|
+
AS $$
|
|
464
|
+
SELECT d.child_resource, d.action
|
|
465
|
+
FROM rbac.resource_dependencies d
|
|
466
|
+
WHERE d.parent_resource = p_parent_resource
|
|
467
|
+
AND d.action = p_action;
|
|
468
|
+
$$;
|
|
469
|
+
|
|
470
|
+
-- 0.4.0+. Atomic replace-all for the dependency graph. The admin
|
|
471
|
+
-- transport calls this on every `syncResources()` so removed
|
|
472
|
+
-- `dependsOn` edges actually disappear from the table. Argument is
|
|
473
|
+
-- a JSONB array of `{ parent_resource, child_resource, action }`
|
|
474
|
+
-- objects.
|
|
475
|
+
CREATE OR REPLACE FUNCTION rbac.replace_resource_dependencies(
|
|
476
|
+
p_edges jsonb
|
|
477
|
+
) RETURNS int
|
|
478
|
+
LANGUAGE plpgsql
|
|
479
|
+
SECURITY DEFINER
|
|
480
|
+
SET search_path = rbac, public
|
|
481
|
+
AS $$
|
|
482
|
+
DECLARE
|
|
483
|
+
v_count int;
|
|
484
|
+
BEGIN
|
|
485
|
+
DELETE FROM rbac.resource_dependencies;
|
|
486
|
+
INSERT INTO rbac.resource_dependencies (
|
|
487
|
+
parent_resource, child_resource, action
|
|
488
|
+
)
|
|
489
|
+
SELECT e->>'parent_resource',
|
|
490
|
+
e->>'child_resource',
|
|
491
|
+
e->>'action'
|
|
492
|
+
FROM jsonb_array_elements(coalesce(p_edges, '[]'::jsonb)) AS e
|
|
493
|
+
ON CONFLICT (parent_resource, child_resource, action) DO NOTHING;
|
|
494
|
+
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
495
|
+
RETURN v_count;
|
|
496
|
+
END;
|
|
497
|
+
$$;
|
|
498
|
+
|
|
364
499
|
-- ─────────────────────────────────────────────────────────────────
|
|
365
500
|
-- 7b. Template defaults — pre-fill role_permissions from a JSONB
|
|
366
501
|
-- pattern carried on the role row. Run after sync_resources() so
|