@grantjs/client 1.0.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/LICENSE +21 -0
- package/README.md +417 -0
- package/dist/grant-client.d.ts +75 -0
- package/dist/grant-client.d.ts.map +1 -0
- package/dist/index.cjs +228 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +228 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react/components/GrantGate.d.ts +58 -0
- package/dist/react/components/GrantGate.d.ts.map +1 -0
- package/dist/react/context.d.ts +73 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/hooks/useGrant.d.ts +61 -0
- package/dist/react/hooks/useGrant.d.ts.map +1 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react.cjs +139 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.mjs +139 -0
- package/dist/react.mjs.map +1 -0
- package/dist/types.d.ts +149 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +96 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Scope } from '../../types';
|
|
2
|
+
/**
|
|
3
|
+
* Options for the useGrant hook
|
|
4
|
+
*/
|
|
5
|
+
export interface UseGrantOptions {
|
|
6
|
+
/** Scope to check the permission in. If provided but null/undefined, hook waits for it to become valid. */
|
|
7
|
+
scope?: Scope | null;
|
|
8
|
+
/** Whether to skip the permission check */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** Whether to use cached results (default: true) */
|
|
11
|
+
useCache?: boolean;
|
|
12
|
+
/** Whether to return loading state (default: false) */
|
|
13
|
+
returnLoading?: boolean;
|
|
14
|
+
/** Context to check permissions for */
|
|
15
|
+
context?: {
|
|
16
|
+
resource?: Record<string, unknown> | null;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Result when returnLoading is true
|
|
21
|
+
*/
|
|
22
|
+
export interface UseGrantResult {
|
|
23
|
+
/** Whether the user is granted permission */
|
|
24
|
+
isGranted: boolean;
|
|
25
|
+
/** Whether the permission check is loading */
|
|
26
|
+
isLoading: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Hook to check if a user is granted permission for a specific resource and action
|
|
30
|
+
*
|
|
31
|
+
* By default, returns a simple boolean, defaulting to false while loading.
|
|
32
|
+
* Set `returnLoading: true` to get an object with `isGranted` and `isLoading`.
|
|
33
|
+
*
|
|
34
|
+
* @param resource - The resource slug to check
|
|
35
|
+
* @param action - The action to check
|
|
36
|
+
* @param options - Additional options
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* // Simple boolean (default)
|
|
41
|
+
* const canEdit = useGrant('document', 'update');
|
|
42
|
+
*
|
|
43
|
+
* return (
|
|
44
|
+
* <div>
|
|
45
|
+
* {canEdit && <EditButton />}
|
|
46
|
+
* </div>
|
|
47
|
+
* );
|
|
48
|
+
*
|
|
49
|
+
* // With loading state
|
|
50
|
+
* const { isGranted, isLoading } = useGrant('document', 'update', {
|
|
51
|
+
* returnLoading: true,
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* if (isLoading) return <Spinner />;
|
|
55
|
+
* if (!isGranted) return null;
|
|
56
|
+
*
|
|
57
|
+
* return <EditButton />;
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export declare function useGrant(resource: string, action: string, options?: UseGrantOptions): boolean | UseGrantResult;
|
|
61
|
+
//# sourceMappingURL=useGrant.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useGrant.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useGrant.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAuB,KAAK,EAAE,MAAM,aAAa,CAAC;AAE9D;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,2GAA2G;IAC3G,KAAK,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC;IACrB,2CAA2C;IAC3C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,oDAAoD;IACpD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uDAAuD;IACvD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,uCAAuC;IACvC,OAAO,CAAC,EAAE;QACR,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;KAC3C,CAAC;CACH;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,6CAA6C;IAC7C,SAAS,EAAE,OAAO,CAAC;IACnB,8CAA8C;IAC9C,SAAS,EAAE,OAAO,CAAC;CACpB;AAWD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,QAAQ,CACtB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,eAAoB,GAC5B,OAAO,GAAG,cAAc,CAqH1B"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { GrantProvider, useGrantClient, useGrantClientOptional } from './context';
|
|
2
|
+
export type { GrantProviderProps } from './context';
|
|
3
|
+
export { useGrant } from './hooks/useGrant';
|
|
4
|
+
export type { UseGrantOptions, UseGrantResult } from './hooks/useGrant';
|
|
5
|
+
export { GrantGate } from './components/GrantGate';
|
|
6
|
+
export type { GrantGateProps } from './components/GrantGate';
|
|
7
|
+
export type { GrantClientConfig, AuthTokens, AuthorizationResult, Permission, Scope, } from '../types';
|
|
8
|
+
export { GrantClient } from '../grant-client';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAClF,YAAY,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAGpD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGxE,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,YAAY,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAG7D,YAAY,EACV,iBAAiB,EACjB,UAAU,EACV,mBAAmB,EACnB,UAAU,EACV,KAAK,GACN,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC"}
|
package/dist/react.cjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const jsxRuntime = require("react/jsx-runtime");
|
|
4
|
+
const react = require("react");
|
|
5
|
+
const index = require("./index.cjs");
|
|
6
|
+
const GrantContext = react.createContext(null);
|
|
7
|
+
function GrantProvider({ config, client, children }) {
|
|
8
|
+
const grantClient = react.useMemo(() => {
|
|
9
|
+
if (client) return client;
|
|
10
|
+
return new index.GrantClient(config);
|
|
11
|
+
}, [client, config]);
|
|
12
|
+
return /* @__PURE__ */ jsxRuntime.jsx(GrantContext.Provider, { value: grantClient, children });
|
|
13
|
+
}
|
|
14
|
+
function useGrantClient() {
|
|
15
|
+
const client = react.useContext(GrantContext);
|
|
16
|
+
if (!client) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
"useGrantClient must be used within a GrantProvider. Wrap your app with <GrantProvider config={...}> to fix this error."
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return client;
|
|
22
|
+
}
|
|
23
|
+
function useGrantClientOptional() {
|
|
24
|
+
return react.useContext(GrantContext);
|
|
25
|
+
}
|
|
26
|
+
function serializeScope(scope) {
|
|
27
|
+
if (!scope) return "";
|
|
28
|
+
return `${scope.tenant}:${scope.id}`;
|
|
29
|
+
}
|
|
30
|
+
function useGrant(resource, action, options = {}) {
|
|
31
|
+
const { scope, enabled = true, useCache = true, returnLoading = false, context } = options;
|
|
32
|
+
const client = useGrantClient();
|
|
33
|
+
const scopeWasProvidedRef = react.useRef("scope" in options);
|
|
34
|
+
const isEffectivelyEnabled = react.useMemo(() => {
|
|
35
|
+
const hasValidScope = scope && typeof scope === "object" && "tenant" in scope && "id" in scope && scope.id;
|
|
36
|
+
const shouldWaitForScope = scopeWasProvidedRef.current && !hasValidScope;
|
|
37
|
+
return enabled && !shouldWaitForScope;
|
|
38
|
+
}, [scope, enabled]);
|
|
39
|
+
const [data, setData] = react.useState(null);
|
|
40
|
+
const [isLoading, setIsLoading] = react.useState(isEffectivelyEnabled);
|
|
41
|
+
const [prevEffectivelyEnabled, setPrevEffectivelyEnabled] = react.useState(isEffectivelyEnabled);
|
|
42
|
+
if (isEffectivelyEnabled !== prevEffectivelyEnabled) {
|
|
43
|
+
setPrevEffectivelyEnabled(isEffectivelyEnabled);
|
|
44
|
+
if (isEffectivelyEnabled) {
|
|
45
|
+
setIsLoading(true);
|
|
46
|
+
} else {
|
|
47
|
+
setIsLoading(false);
|
|
48
|
+
setData(null);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const isMounted = react.useRef(true);
|
|
52
|
+
const scopeRef = react.useRef(scope);
|
|
53
|
+
scopeRef.current = scope;
|
|
54
|
+
const contextRef = react.useRef(context);
|
|
55
|
+
contextRef.current = context;
|
|
56
|
+
const scopeKey = serializeScope(scope);
|
|
57
|
+
const contextKey = react.useMemo(
|
|
58
|
+
() => context?.resource != null ? JSON.stringify(context.resource) : "",
|
|
59
|
+
[context?.resource]
|
|
60
|
+
);
|
|
61
|
+
const fetchPermission = react.useCallback(async () => {
|
|
62
|
+
if (!isEffectivelyEnabled) {
|
|
63
|
+
setIsLoading(false);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setIsLoading(true);
|
|
67
|
+
try {
|
|
68
|
+
const result = await client.isAuthorized(resource, action, {
|
|
69
|
+
scope: scopeRef.current ?? void 0,
|
|
70
|
+
useCache,
|
|
71
|
+
context: contextRef.current
|
|
72
|
+
});
|
|
73
|
+
if (isMounted.current) {
|
|
74
|
+
setData(result);
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
if (isMounted.current) {
|
|
78
|
+
setData(null);
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
if (isMounted.current) {
|
|
82
|
+
setIsLoading(false);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}, [client, resource, action, scopeKey, isEffectivelyEnabled, useCache, contextKey]);
|
|
86
|
+
react.useEffect(() => {
|
|
87
|
+
isMounted.current = true;
|
|
88
|
+
if (!isEffectivelyEnabled && scopeWasProvidedRef.current) {
|
|
89
|
+
setData(null);
|
|
90
|
+
setIsLoading(false);
|
|
91
|
+
} else {
|
|
92
|
+
fetchPermission();
|
|
93
|
+
}
|
|
94
|
+
return () => {
|
|
95
|
+
isMounted.current = false;
|
|
96
|
+
};
|
|
97
|
+
}, [fetchPermission, isEffectivelyEnabled]);
|
|
98
|
+
const isGranted = data?.authorized ?? false;
|
|
99
|
+
if (returnLoading) {
|
|
100
|
+
return { isGranted, isLoading };
|
|
101
|
+
}
|
|
102
|
+
return isGranted;
|
|
103
|
+
}
|
|
104
|
+
function GrantGate({
|
|
105
|
+
resource,
|
|
106
|
+
action,
|
|
107
|
+
scope,
|
|
108
|
+
enabled,
|
|
109
|
+
useCache,
|
|
110
|
+
children,
|
|
111
|
+
fallback = null,
|
|
112
|
+
loading = null
|
|
113
|
+
}) {
|
|
114
|
+
const options = {
|
|
115
|
+
enabled,
|
|
116
|
+
useCache,
|
|
117
|
+
returnLoading: loading !== null
|
|
118
|
+
};
|
|
119
|
+
if (scope !== void 0) {
|
|
120
|
+
options.scope = scope;
|
|
121
|
+
}
|
|
122
|
+
const result = useGrant(resource, action, options);
|
|
123
|
+
const isGranted = typeof result === "boolean" ? result : result.isGranted;
|
|
124
|
+
const isLoading = typeof result === "boolean" ? false : result.isLoading;
|
|
125
|
+
if (isLoading && loading !== null) {
|
|
126
|
+
return loading;
|
|
127
|
+
}
|
|
128
|
+
if (isGranted) {
|
|
129
|
+
return children;
|
|
130
|
+
}
|
|
131
|
+
return fallback;
|
|
132
|
+
}
|
|
133
|
+
exports.GrantClient = index.GrantClient;
|
|
134
|
+
exports.GrantGate = GrantGate;
|
|
135
|
+
exports.GrantProvider = GrantProvider;
|
|
136
|
+
exports.useGrant = useGrant;
|
|
137
|
+
exports.useGrantClient = useGrantClient;
|
|
138
|
+
exports.useGrantClientOptional = useGrantClientOptional;
|
|
139
|
+
//# sourceMappingURL=react.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.cjs","sources":["../src/react/context.tsx","../src/react/hooks/useGrant.ts","../src/react/components/GrantGate.tsx"],"sourcesContent":["'use client';\n\nimport { createContext, useContext, useMemo, type ReactNode } from 'react';\n\nimport { GrantClient } from '../grant-client';\n\nimport type { GrantClientConfig } from '../types';\n\n/**\n * Context for the Grant client\n */\nconst GrantContext = createContext<GrantClient | null>(null);\n\n/**\n * Props for the GrantProvider component\n */\nexport interface GrantProviderProps {\n /**\n * Grant client configuration\n */\n config: GrantClientConfig;\n\n /**\n * Pre-configured GrantClient instance (alternative to config)\n * If provided, config is ignored\n */\n client?: GrantClient;\n\n /**\n * Child components\n */\n children: ReactNode;\n}\n\n/**\n * Provider component that makes the Grant client available to child components\n *\n * @example\n * ```tsx\n * // Option 1: Pass config (cookie-based refresh)\n * <GrantProvider\n * config={{\n * apiUrl: 'https://api.grant.com',\n * getAccessToken: () => localStorage.getItem('accessToken'),\n * onRefreshWithCredentials: async () => {\n * const res = await fetch('https://api.grant.com/api/auth/refresh', { method: 'POST', credentials: 'include' });\n * if (!res.ok) return false;\n * const { data } = await res.json();\n * if (data?.accessToken) { localStorage.setItem('accessToken', data.accessToken); return true; }\n * return false;\n * },\n * onTokenRefresh: (tokens) => { localStorage.setItem('accessToken', tokens.accessToken); },\n * onUnauthorized: () => { window.location.href = '/login'; },\n * }}\n * >\n * <App />\n * </GrantProvider>\n *\n * // Option 2: Pass pre-configured client\n * const grant = new GrantClient({ ... });\n * <GrantProvider client={grant}>\n * <App />\n * </GrantProvider>\n * ```\n */\nexport function GrantProvider({ config, client, children }: GrantProviderProps) {\n const grantClient = useMemo(() => {\n if (client) return client;\n return new GrantClient(config);\n }, [client, config]);\n\n return <GrantContext.Provider value={grantClient}>{children}</GrantContext.Provider>;\n}\n\n/**\n * Hook to access the Grant client from context\n *\n * @throws Error if used outside of GrantProvider\n *\n * @example\n * ```tsx\n * const grant = useGrantClient();\n * const hasPermission = await grant.can('resource', 'action');\n * ```\n */\nexport function useGrantClient(): GrantClient {\n const client = useContext(GrantContext);\n\n if (!client) {\n throw new Error(\n 'useGrantClient must be used within a GrantProvider. ' +\n 'Wrap your app with <GrantProvider config={...}> to fix this error.'\n );\n }\n\n return client;\n}\n\n/**\n * Hook to optionally access the Grant client\n * Returns null if not in a GrantProvider context\n *\n * Use this when you want to gracefully handle missing provider\n */\nexport function useGrantClientOptional(): GrantClient | null {\n return useContext(GrantContext);\n}\n","'use client';\n\nimport { useState, useEffect, useCallback, useRef, useMemo } from 'react';\n\nimport { useGrantClient } from '../context';\n\nimport type { AuthorizationResult, Scope } from '../../types';\n\n/**\n * Options for the useGrant hook\n */\nexport interface UseGrantOptions {\n /** Scope to check the permission in. If provided but null/undefined, hook waits for it to become valid. */\n scope?: Scope | null;\n /** Whether to skip the permission check */\n enabled?: boolean;\n /** Whether to use cached results (default: true) */\n useCache?: boolean;\n /** Whether to return loading state (default: false) */\n returnLoading?: boolean;\n /** Context to check permissions for */\n context?: {\n resource?: Record<string, unknown> | null;\n };\n}\n\n/**\n * Result when returnLoading is true\n */\nexport interface UseGrantResult {\n /** Whether the user is granted permission */\n isGranted: boolean;\n /** Whether the permission check is loading */\n isLoading: boolean;\n}\n\n/**\n * Serialize scope for stable dependency comparison\n * This prevents re-fetching when scope object reference changes but values are the same\n */\nfunction serializeScope(scope?: Scope | null): string {\n if (!scope) return '';\n return `${scope.tenant}:${scope.id}`;\n}\n\n/**\n * Hook to check if a user is granted permission for a specific resource and action\n *\n * By default, returns a simple boolean, defaulting to false while loading.\n * Set `returnLoading: true` to get an object with `isGranted` and `isLoading`.\n *\n * @param resource - The resource slug to check\n * @param action - The action to check\n * @param options - Additional options\n *\n * @example\n * ```tsx\n * // Simple boolean (default)\n * const canEdit = useGrant('document', 'update');\n *\n * return (\n * <div>\n * {canEdit && <EditButton />}\n * </div>\n * );\n *\n * // With loading state\n * const { isGranted, isLoading } = useGrant('document', 'update', {\n * returnLoading: true,\n * });\n *\n * if (isLoading) return <Spinner />;\n * if (!isGranted) return null;\n *\n * return <EditButton />;\n * ```\n */\nexport function useGrant(\n resource: string,\n action: string,\n options: UseGrantOptions = {}\n): boolean | UseGrantResult {\n const { scope, enabled = true, useCache = true, returnLoading = false, context } = options;\n const client = useGrantClient();\n\n // Track if scope was explicitly provided (even if null/undefined)\n // This allows us to distinguish between \"scope not provided\" (optional) vs \"scope provided but falsy\" (wait for it)\n // Check this once at the start - if scope key exists in options, it was provided\n // Note: { scope: undefined } has the key, { } does not have the key\n const scopeWasProvidedRef = useRef('scope' in options);\n\n // Determine if we should wait for scope to become valid\n // If scope was provided but is falsy or invalid, wait for it to become truthy\n // Recalculate when scope changes\n const isEffectivelyEnabled = useMemo(() => {\n const hasValidScope =\n scope && typeof scope === 'object' && 'tenant' in scope && 'id' in scope && scope.id;\n const shouldWaitForScope = scopeWasProvidedRef.current && !hasValidScope;\n return enabled && !shouldWaitForScope;\n }, [scope, enabled]);\n\n const [data, setData] = useState<AuthorizationResult | null>(null);\n const [isLoading, setIsLoading] = useState(isEffectivelyEnabled);\n\n // Synchronously correct isLoading when isEffectivelyEnabled transitions.\n // useState only uses its initializer on first render, so subsequent transitions\n // leave isLoading stale for one render cycle (the effect hasn't run yet).\n // This uses React's \"storing information from previous renders\" pattern to\n // immediately set isLoading before the render completes.\n // See: https://react.dev/reference/react/useState#storing-information-from-previous-renders\n const [prevEffectivelyEnabled, setPrevEffectivelyEnabled] = useState(isEffectivelyEnabled);\n if (isEffectivelyEnabled !== prevEffectivelyEnabled) {\n setPrevEffectivelyEnabled(isEffectivelyEnabled);\n if (isEffectivelyEnabled) {\n setIsLoading(true);\n } else {\n setIsLoading(false);\n setData(null);\n }\n }\n\n // Track mounted state to prevent state updates after unmount\n const isMounted = useRef(true);\n\n // Store scope in a ref so we always have the latest value without triggering re-renders\n const scopeRef = useRef(scope);\n scopeRef.current = scope;\n\n // Store context in a ref so the callback always sends the latest context\n const contextRef = useRef(context);\n contextRef.current = context;\n\n // Serialize scope to get a stable string for dependency comparison\n const scopeKey = serializeScope(scope);\n\n // Serialize context so we re-create the callback when context meaningfully changes\n const contextKey = useMemo(\n () => (context?.resource != null ? JSON.stringify(context.resource) : ''),\n [context?.resource]\n );\n\n const fetchPermission = useCallback(async () => {\n if (!isEffectivelyEnabled) {\n setIsLoading(false);\n return;\n }\n\n setIsLoading(true);\n\n try {\n // Use scopeRef.current and contextRef.current to get the latest values\n // Convert null to undefined for the client (which expects Scope | undefined)\n const result = await client.isAuthorized(resource, action, {\n scope: scopeRef.current ?? undefined,\n useCache,\n context: contextRef.current,\n });\n if (isMounted.current) {\n setData(result);\n }\n } catch {\n // On error, set data to null (will return false)\n if (isMounted.current) {\n setData(null);\n }\n } finally {\n if (isMounted.current) {\n setIsLoading(false);\n }\n }\n // contextKey ensures we re-run when context (e.g. resource) changes so the request gets the latest context\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [client, resource, action, scopeKey, isEffectivelyEnabled, useCache, contextKey]);\n\n useEffect(() => {\n isMounted.current = true;\n\n // Clear data when scope becomes invalid (waiting for valid scope)\n if (!isEffectivelyEnabled && scopeWasProvidedRef.current) {\n setData(null);\n setIsLoading(false);\n } else {\n fetchPermission();\n }\n\n return () => {\n isMounted.current = false;\n };\n }, [fetchPermission, isEffectivelyEnabled]);\n\n const isGranted = data?.authorized ?? false;\n\n // Return object with loading state if requested, otherwise just boolean\n if (returnLoading) {\n return { isGranted, isLoading };\n }\n\n return isGranted;\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\n\nimport { useGrant, type UseGrantOptions } from '../hooks/useGrant';\n\n/**\n * Props for the GrantGate component\n */\nexport interface GrantGateProps extends UseGrantOptions {\n /** The resource slug to check permission for */\n resource: string;\n /** The action to check */\n action: string;\n /** Content to render if permission is granted */\n children: ReactNode;\n /** Content to render if permission is denied (optional) */\n fallback?: ReactNode;\n /** Content to render while loading (optional) */\n loading?: ReactNode;\n}\n\n/**\n * Component that conditionally renders children based on permissions\n *\n * @example\n * ```tsx\n * // Basic usage - hide element if no permission\n * <GrantGate resource=\"document\" action=\"update\">\n * <EditButton />\n * </GrantGate>\n *\n * // With fallback for denied access\n * <GrantGate\n * resource=\"admin\"\n * action=\"access\"\n * fallback={<p>You don't have admin access</p>}\n * >\n * <AdminPanel />\n * </GrantGate>\n *\n * // With loading state\n * <GrantGate\n * resource=\"report\"\n * action=\"view\"\n * loading={<Spinner />}\n * fallback={<AccessDenied />}\n * >\n * <ReportViewer />\n * </GrantGate>\n *\n * // With scope for multi-tenant\n * <GrantGate\n * resource=\"project\"\n * action=\"delete\"\n * scope={{ tenant: 'project', id: projectId }}\n * >\n * <DeleteProjectButton />\n * </GrantGate>\n * ```\n */\nexport function GrantGate({\n resource,\n action,\n scope,\n enabled,\n useCache,\n children,\n fallback = null,\n loading = null,\n}: GrantGateProps): ReactNode {\n // Build options object conditionally\n // Only include scope in options if it's not undefined (null is valid and means \"wait for it\")\n // This allows the hook to distinguish between \"scope not provided\" (undefined) vs \"scope provided but null\"\n const options: Parameters<typeof useGrant>[2] = {\n enabled,\n useCache,\n returnLoading: loading !== null,\n };\n\n // Only add scope to options if it's explicitly null or a valid object\n // If scope is undefined, don't include it so hook treats it as optional\n if (scope !== undefined) {\n options.scope = scope;\n }\n\n // Use loading state if loading prop is provided\n const result = useGrant(resource, action, options);\n\n const isGranted = typeof result === 'boolean' ? result : result.isGranted;\n const isLoading = typeof result === 'boolean' ? false : result.isLoading;\n\n if (isLoading && loading !== null) {\n return loading;\n }\n\n if (isGranted) {\n return children;\n }\n\n return fallback;\n}\n"],"names":["createContext","useMemo","GrantClient","useContext","useRef","useState","useCallback","useEffect"],"mappings":"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"}
|
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useContext, createContext, useRef, useState, useCallback, useEffect } from "react";
|
|
3
|
+
import { GrantClient } from "./index.mjs";
|
|
4
|
+
const GrantContext = createContext(null);
|
|
5
|
+
function GrantProvider({ config, client, children }) {
|
|
6
|
+
const grantClient = useMemo(() => {
|
|
7
|
+
if (client) return client;
|
|
8
|
+
return new GrantClient(config);
|
|
9
|
+
}, [client, config]);
|
|
10
|
+
return /* @__PURE__ */ jsx(GrantContext.Provider, { value: grantClient, children });
|
|
11
|
+
}
|
|
12
|
+
function useGrantClient() {
|
|
13
|
+
const client = useContext(GrantContext);
|
|
14
|
+
if (!client) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"useGrantClient must be used within a GrantProvider. Wrap your app with <GrantProvider config={...}> to fix this error."
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return client;
|
|
20
|
+
}
|
|
21
|
+
function useGrantClientOptional() {
|
|
22
|
+
return useContext(GrantContext);
|
|
23
|
+
}
|
|
24
|
+
function serializeScope(scope) {
|
|
25
|
+
if (!scope) return "";
|
|
26
|
+
return `${scope.tenant}:${scope.id}`;
|
|
27
|
+
}
|
|
28
|
+
function useGrant(resource, action, options = {}) {
|
|
29
|
+
const { scope, enabled = true, useCache = true, returnLoading = false, context } = options;
|
|
30
|
+
const client = useGrantClient();
|
|
31
|
+
const scopeWasProvidedRef = useRef("scope" in options);
|
|
32
|
+
const isEffectivelyEnabled = useMemo(() => {
|
|
33
|
+
const hasValidScope = scope && typeof scope === "object" && "tenant" in scope && "id" in scope && scope.id;
|
|
34
|
+
const shouldWaitForScope = scopeWasProvidedRef.current && !hasValidScope;
|
|
35
|
+
return enabled && !shouldWaitForScope;
|
|
36
|
+
}, [scope, enabled]);
|
|
37
|
+
const [data, setData] = useState(null);
|
|
38
|
+
const [isLoading, setIsLoading] = useState(isEffectivelyEnabled);
|
|
39
|
+
const [prevEffectivelyEnabled, setPrevEffectivelyEnabled] = useState(isEffectivelyEnabled);
|
|
40
|
+
if (isEffectivelyEnabled !== prevEffectivelyEnabled) {
|
|
41
|
+
setPrevEffectivelyEnabled(isEffectivelyEnabled);
|
|
42
|
+
if (isEffectivelyEnabled) {
|
|
43
|
+
setIsLoading(true);
|
|
44
|
+
} else {
|
|
45
|
+
setIsLoading(false);
|
|
46
|
+
setData(null);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const isMounted = useRef(true);
|
|
50
|
+
const scopeRef = useRef(scope);
|
|
51
|
+
scopeRef.current = scope;
|
|
52
|
+
const contextRef = useRef(context);
|
|
53
|
+
contextRef.current = context;
|
|
54
|
+
const scopeKey = serializeScope(scope);
|
|
55
|
+
const contextKey = useMemo(
|
|
56
|
+
() => context?.resource != null ? JSON.stringify(context.resource) : "",
|
|
57
|
+
[context?.resource]
|
|
58
|
+
);
|
|
59
|
+
const fetchPermission = useCallback(async () => {
|
|
60
|
+
if (!isEffectivelyEnabled) {
|
|
61
|
+
setIsLoading(false);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
setIsLoading(true);
|
|
65
|
+
try {
|
|
66
|
+
const result = await client.isAuthorized(resource, action, {
|
|
67
|
+
scope: scopeRef.current ?? void 0,
|
|
68
|
+
useCache,
|
|
69
|
+
context: contextRef.current
|
|
70
|
+
});
|
|
71
|
+
if (isMounted.current) {
|
|
72
|
+
setData(result);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
if (isMounted.current) {
|
|
76
|
+
setData(null);
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
if (isMounted.current) {
|
|
80
|
+
setIsLoading(false);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}, [client, resource, action, scopeKey, isEffectivelyEnabled, useCache, contextKey]);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
isMounted.current = true;
|
|
86
|
+
if (!isEffectivelyEnabled && scopeWasProvidedRef.current) {
|
|
87
|
+
setData(null);
|
|
88
|
+
setIsLoading(false);
|
|
89
|
+
} else {
|
|
90
|
+
fetchPermission();
|
|
91
|
+
}
|
|
92
|
+
return () => {
|
|
93
|
+
isMounted.current = false;
|
|
94
|
+
};
|
|
95
|
+
}, [fetchPermission, isEffectivelyEnabled]);
|
|
96
|
+
const isGranted = data?.authorized ?? false;
|
|
97
|
+
if (returnLoading) {
|
|
98
|
+
return { isGranted, isLoading };
|
|
99
|
+
}
|
|
100
|
+
return isGranted;
|
|
101
|
+
}
|
|
102
|
+
function GrantGate({
|
|
103
|
+
resource,
|
|
104
|
+
action,
|
|
105
|
+
scope,
|
|
106
|
+
enabled,
|
|
107
|
+
useCache,
|
|
108
|
+
children,
|
|
109
|
+
fallback = null,
|
|
110
|
+
loading = null
|
|
111
|
+
}) {
|
|
112
|
+
const options = {
|
|
113
|
+
enabled,
|
|
114
|
+
useCache,
|
|
115
|
+
returnLoading: loading !== null
|
|
116
|
+
};
|
|
117
|
+
if (scope !== void 0) {
|
|
118
|
+
options.scope = scope;
|
|
119
|
+
}
|
|
120
|
+
const result = useGrant(resource, action, options);
|
|
121
|
+
const isGranted = typeof result === "boolean" ? result : result.isGranted;
|
|
122
|
+
const isLoading = typeof result === "boolean" ? false : result.isLoading;
|
|
123
|
+
if (isLoading && loading !== null) {
|
|
124
|
+
return loading;
|
|
125
|
+
}
|
|
126
|
+
if (isGranted) {
|
|
127
|
+
return children;
|
|
128
|
+
}
|
|
129
|
+
return fallback;
|
|
130
|
+
}
|
|
131
|
+
export {
|
|
132
|
+
GrantClient,
|
|
133
|
+
GrantGate,
|
|
134
|
+
GrantProvider,
|
|
135
|
+
useGrant,
|
|
136
|
+
useGrantClient,
|
|
137
|
+
useGrantClientOptional
|
|
138
|
+
};
|
|
139
|
+
//# sourceMappingURL=react.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.mjs","sources":["../src/react/context.tsx","../src/react/hooks/useGrant.ts","../src/react/components/GrantGate.tsx"],"sourcesContent":["'use client';\n\nimport { createContext, useContext, useMemo, type ReactNode } from 'react';\n\nimport { GrantClient } from '../grant-client';\n\nimport type { GrantClientConfig } from '../types';\n\n/**\n * Context for the Grant client\n */\nconst GrantContext = createContext<GrantClient | null>(null);\n\n/**\n * Props for the GrantProvider component\n */\nexport interface GrantProviderProps {\n /**\n * Grant client configuration\n */\n config: GrantClientConfig;\n\n /**\n * Pre-configured GrantClient instance (alternative to config)\n * If provided, config is ignored\n */\n client?: GrantClient;\n\n /**\n * Child components\n */\n children: ReactNode;\n}\n\n/**\n * Provider component that makes the Grant client available to child components\n *\n * @example\n * ```tsx\n * // Option 1: Pass config (cookie-based refresh)\n * <GrantProvider\n * config={{\n * apiUrl: 'https://api.grant.com',\n * getAccessToken: () => localStorage.getItem('accessToken'),\n * onRefreshWithCredentials: async () => {\n * const res = await fetch('https://api.grant.com/api/auth/refresh', { method: 'POST', credentials: 'include' });\n * if (!res.ok) return false;\n * const { data } = await res.json();\n * if (data?.accessToken) { localStorage.setItem('accessToken', data.accessToken); return true; }\n * return false;\n * },\n * onTokenRefresh: (tokens) => { localStorage.setItem('accessToken', tokens.accessToken); },\n * onUnauthorized: () => { window.location.href = '/login'; },\n * }}\n * >\n * <App />\n * </GrantProvider>\n *\n * // Option 2: Pass pre-configured client\n * const grant = new GrantClient({ ... });\n * <GrantProvider client={grant}>\n * <App />\n * </GrantProvider>\n * ```\n */\nexport function GrantProvider({ config, client, children }: GrantProviderProps) {\n const grantClient = useMemo(() => {\n if (client) return client;\n return new GrantClient(config);\n }, [client, config]);\n\n return <GrantContext.Provider value={grantClient}>{children}</GrantContext.Provider>;\n}\n\n/**\n * Hook to access the Grant client from context\n *\n * @throws Error if used outside of GrantProvider\n *\n * @example\n * ```tsx\n * const grant = useGrantClient();\n * const hasPermission = await grant.can('resource', 'action');\n * ```\n */\nexport function useGrantClient(): GrantClient {\n const client = useContext(GrantContext);\n\n if (!client) {\n throw new Error(\n 'useGrantClient must be used within a GrantProvider. ' +\n 'Wrap your app with <GrantProvider config={...}> to fix this error.'\n );\n }\n\n return client;\n}\n\n/**\n * Hook to optionally access the Grant client\n * Returns null if not in a GrantProvider context\n *\n * Use this when you want to gracefully handle missing provider\n */\nexport function useGrantClientOptional(): GrantClient | null {\n return useContext(GrantContext);\n}\n","'use client';\n\nimport { useState, useEffect, useCallback, useRef, useMemo } from 'react';\n\nimport { useGrantClient } from '../context';\n\nimport type { AuthorizationResult, Scope } from '../../types';\n\n/**\n * Options for the useGrant hook\n */\nexport interface UseGrantOptions {\n /** Scope to check the permission in. If provided but null/undefined, hook waits for it to become valid. */\n scope?: Scope | null;\n /** Whether to skip the permission check */\n enabled?: boolean;\n /** Whether to use cached results (default: true) */\n useCache?: boolean;\n /** Whether to return loading state (default: false) */\n returnLoading?: boolean;\n /** Context to check permissions for */\n context?: {\n resource?: Record<string, unknown> | null;\n };\n}\n\n/**\n * Result when returnLoading is true\n */\nexport interface UseGrantResult {\n /** Whether the user is granted permission */\n isGranted: boolean;\n /** Whether the permission check is loading */\n isLoading: boolean;\n}\n\n/**\n * Serialize scope for stable dependency comparison\n * This prevents re-fetching when scope object reference changes but values are the same\n */\nfunction serializeScope(scope?: Scope | null): string {\n if (!scope) return '';\n return `${scope.tenant}:${scope.id}`;\n}\n\n/**\n * Hook to check if a user is granted permission for a specific resource and action\n *\n * By default, returns a simple boolean, defaulting to false while loading.\n * Set `returnLoading: true` to get an object with `isGranted` and `isLoading`.\n *\n * @param resource - The resource slug to check\n * @param action - The action to check\n * @param options - Additional options\n *\n * @example\n * ```tsx\n * // Simple boolean (default)\n * const canEdit = useGrant('document', 'update');\n *\n * return (\n * <div>\n * {canEdit && <EditButton />}\n * </div>\n * );\n *\n * // With loading state\n * const { isGranted, isLoading } = useGrant('document', 'update', {\n * returnLoading: true,\n * });\n *\n * if (isLoading) return <Spinner />;\n * if (!isGranted) return null;\n *\n * return <EditButton />;\n * ```\n */\nexport function useGrant(\n resource: string,\n action: string,\n options: UseGrantOptions = {}\n): boolean | UseGrantResult {\n const { scope, enabled = true, useCache = true, returnLoading = false, context } = options;\n const client = useGrantClient();\n\n // Track if scope was explicitly provided (even if null/undefined)\n // This allows us to distinguish between \"scope not provided\" (optional) vs \"scope provided but falsy\" (wait for it)\n // Check this once at the start - if scope key exists in options, it was provided\n // Note: { scope: undefined } has the key, { } does not have the key\n const scopeWasProvidedRef = useRef('scope' in options);\n\n // Determine if we should wait for scope to become valid\n // If scope was provided but is falsy or invalid, wait for it to become truthy\n // Recalculate when scope changes\n const isEffectivelyEnabled = useMemo(() => {\n const hasValidScope =\n scope && typeof scope === 'object' && 'tenant' in scope && 'id' in scope && scope.id;\n const shouldWaitForScope = scopeWasProvidedRef.current && !hasValidScope;\n return enabled && !shouldWaitForScope;\n }, [scope, enabled]);\n\n const [data, setData] = useState<AuthorizationResult | null>(null);\n const [isLoading, setIsLoading] = useState(isEffectivelyEnabled);\n\n // Synchronously correct isLoading when isEffectivelyEnabled transitions.\n // useState only uses its initializer on first render, so subsequent transitions\n // leave isLoading stale for one render cycle (the effect hasn't run yet).\n // This uses React's \"storing information from previous renders\" pattern to\n // immediately set isLoading before the render completes.\n // See: https://react.dev/reference/react/useState#storing-information-from-previous-renders\n const [prevEffectivelyEnabled, setPrevEffectivelyEnabled] = useState(isEffectivelyEnabled);\n if (isEffectivelyEnabled !== prevEffectivelyEnabled) {\n setPrevEffectivelyEnabled(isEffectivelyEnabled);\n if (isEffectivelyEnabled) {\n setIsLoading(true);\n } else {\n setIsLoading(false);\n setData(null);\n }\n }\n\n // Track mounted state to prevent state updates after unmount\n const isMounted = useRef(true);\n\n // Store scope in a ref so we always have the latest value without triggering re-renders\n const scopeRef = useRef(scope);\n scopeRef.current = scope;\n\n // Store context in a ref so the callback always sends the latest context\n const contextRef = useRef(context);\n contextRef.current = context;\n\n // Serialize scope to get a stable string for dependency comparison\n const scopeKey = serializeScope(scope);\n\n // Serialize context so we re-create the callback when context meaningfully changes\n const contextKey = useMemo(\n () => (context?.resource != null ? JSON.stringify(context.resource) : ''),\n [context?.resource]\n );\n\n const fetchPermission = useCallback(async () => {\n if (!isEffectivelyEnabled) {\n setIsLoading(false);\n return;\n }\n\n setIsLoading(true);\n\n try {\n // Use scopeRef.current and contextRef.current to get the latest values\n // Convert null to undefined for the client (which expects Scope | undefined)\n const result = await client.isAuthorized(resource, action, {\n scope: scopeRef.current ?? undefined,\n useCache,\n context: contextRef.current,\n });\n if (isMounted.current) {\n setData(result);\n }\n } catch {\n // On error, set data to null (will return false)\n if (isMounted.current) {\n setData(null);\n }\n } finally {\n if (isMounted.current) {\n setIsLoading(false);\n }\n }\n // contextKey ensures we re-run when context (e.g. resource) changes so the request gets the latest context\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [client, resource, action, scopeKey, isEffectivelyEnabled, useCache, contextKey]);\n\n useEffect(() => {\n isMounted.current = true;\n\n // Clear data when scope becomes invalid (waiting for valid scope)\n if (!isEffectivelyEnabled && scopeWasProvidedRef.current) {\n setData(null);\n setIsLoading(false);\n } else {\n fetchPermission();\n }\n\n return () => {\n isMounted.current = false;\n };\n }, [fetchPermission, isEffectivelyEnabled]);\n\n const isGranted = data?.authorized ?? false;\n\n // Return object with loading state if requested, otherwise just boolean\n if (returnLoading) {\n return { isGranted, isLoading };\n }\n\n return isGranted;\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\n\nimport { useGrant, type UseGrantOptions } from '../hooks/useGrant';\n\n/**\n * Props for the GrantGate component\n */\nexport interface GrantGateProps extends UseGrantOptions {\n /** The resource slug to check permission for */\n resource: string;\n /** The action to check */\n action: string;\n /** Content to render if permission is granted */\n children: ReactNode;\n /** Content to render if permission is denied (optional) */\n fallback?: ReactNode;\n /** Content to render while loading (optional) */\n loading?: ReactNode;\n}\n\n/**\n * Component that conditionally renders children based on permissions\n *\n * @example\n * ```tsx\n * // Basic usage - hide element if no permission\n * <GrantGate resource=\"document\" action=\"update\">\n * <EditButton />\n * </GrantGate>\n *\n * // With fallback for denied access\n * <GrantGate\n * resource=\"admin\"\n * action=\"access\"\n * fallback={<p>You don't have admin access</p>}\n * >\n * <AdminPanel />\n * </GrantGate>\n *\n * // With loading state\n * <GrantGate\n * resource=\"report\"\n * action=\"view\"\n * loading={<Spinner />}\n * fallback={<AccessDenied />}\n * >\n * <ReportViewer />\n * </GrantGate>\n *\n * // With scope for multi-tenant\n * <GrantGate\n * resource=\"project\"\n * action=\"delete\"\n * scope={{ tenant: 'project', id: projectId }}\n * >\n * <DeleteProjectButton />\n * </GrantGate>\n * ```\n */\nexport function GrantGate({\n resource,\n action,\n scope,\n enabled,\n useCache,\n children,\n fallback = null,\n loading = null,\n}: GrantGateProps): ReactNode {\n // Build options object conditionally\n // Only include scope in options if it's not undefined (null is valid and means \"wait for it\")\n // This allows the hook to distinguish between \"scope not provided\" (undefined) vs \"scope provided but null\"\n const options: Parameters<typeof useGrant>[2] = {\n enabled,\n useCache,\n returnLoading: loading !== null,\n };\n\n // Only add scope to options if it's explicitly null or a valid object\n // If scope is undefined, don't include it so hook treats it as optional\n if (scope !== undefined) {\n options.scope = scope;\n }\n\n // Use loading state if loading prop is provided\n const result = useGrant(resource, action, options);\n\n const isGranted = typeof result === 'boolean' ? result : result.isGranted;\n const isLoading = typeof result === 'boolean' ? false : result.isLoading;\n\n if (isLoading && loading !== null) {\n return loading;\n }\n\n if (isGranted) {\n return children;\n }\n\n return fallback;\n}\n"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Scope } from '../../schema/src/index.ts';
|
|
2
|
+
export type { Scope, Tenant } from '../../schema/src/index.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Options for project-app OAuth sign-in (redirect only).
|
|
5
|
+
*/
|
|
6
|
+
export interface SignInWithProjectAppOptions {
|
|
7
|
+
/** Project app client_id */
|
|
8
|
+
clientId: string;
|
|
9
|
+
/** Callback URL; user is redirected here with token in the URL fragment after consent */
|
|
10
|
+
redirectUri: string;
|
|
11
|
+
/** Optional scope (if app supports dynamic scope) */
|
|
12
|
+
scope?: string;
|
|
13
|
+
/** Optional state to round-trip */
|
|
14
|
+
state?: string;
|
|
15
|
+
/** Locale for entry URL (e.g. 'en'). Default 'en'. */
|
|
16
|
+
locale?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Configuration for the Grant client
|
|
20
|
+
*/
|
|
21
|
+
export interface GrantClientConfig {
|
|
22
|
+
/**
|
|
23
|
+
* Grant API URL (e.g., "https://api.grant.com")
|
|
24
|
+
*/
|
|
25
|
+
apiUrl: string;
|
|
26
|
+
/**
|
|
27
|
+
* Grant web app (frontend) URL for project OAuth entry (e.g. "https://app.grant.com").
|
|
28
|
+
* Required for signInWithProjectApp. Entry URL: {frontendUrl}/{locale}/auth/project.
|
|
29
|
+
*/
|
|
30
|
+
frontendUrl?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Function to get the current access token
|
|
33
|
+
* Return null if not authenticated
|
|
34
|
+
*/
|
|
35
|
+
getAccessToken?: () => string | null | Promise<string | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Callback when the access token is updated after a cookie-based refresh.
|
|
38
|
+
* The API returns only `accessToken` in the refresh response body; the refresh token stays in an HttpOnly cookie.
|
|
39
|
+
* Use this to update your in-memory or cookie-based access token so subsequent requests use the new token.
|
|
40
|
+
*/
|
|
41
|
+
onTokenRefresh?: (tokens: AuthTokens) => void | Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Callback when authentication fails (after refresh attempt)
|
|
44
|
+
* Use this to redirect to login
|
|
45
|
+
*/
|
|
46
|
+
onUnauthorized?: () => void;
|
|
47
|
+
/**
|
|
48
|
+
* **Session refresh (cookie-based).** Called on 401 to refresh the session using the HttpOnly refresh cookie.
|
|
49
|
+
* Your callback should: (1) call `POST /api/auth/refresh` with `credentials: 'include'`, (2) parse the
|
|
50
|
+
* response for the new `accessToken`, (3) update your app token storage (e.g. set the new access token so
|
|
51
|
+
* `getAccessToken` returns it), and optionally call the same logic you pass to `onTokenRefresh`. Return `true`
|
|
52
|
+
* if refresh succeeded so the client can retry the request.
|
|
53
|
+
* Refresh tokens are not sent in the request body; the API uses only the HttpOnly refresh cookie.
|
|
54
|
+
*/
|
|
55
|
+
onRefreshWithCredentials?: () => Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Custom fetch implementation
|
|
58
|
+
* Defaults to globalThis.fetch
|
|
59
|
+
*/
|
|
60
|
+
fetch?: typeof fetch;
|
|
61
|
+
/**
|
|
62
|
+
* Credentials mode for fetch requests
|
|
63
|
+
* Defaults to 'include' for cookie support
|
|
64
|
+
*/
|
|
65
|
+
credentials?: RequestCredentials;
|
|
66
|
+
/**
|
|
67
|
+
* Cache configuration
|
|
68
|
+
*/
|
|
69
|
+
cache?: CacheOptions;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Auth tokens from the refresh endpoint. With cookie-based refresh, the API returns only `accessToken` in the
|
|
73
|
+
* response body; `refreshToken` is set in an HttpOnly cookie and is not exposed to JS, so it may be undefined.
|
|
74
|
+
*/
|
|
75
|
+
export interface AuthTokens {
|
|
76
|
+
accessToken: string;
|
|
77
|
+
/** Undefined when using cookie-based refresh (refresh token is HttpOnly cookie). */
|
|
78
|
+
refreshToken?: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Cache configuration options
|
|
82
|
+
*/
|
|
83
|
+
export interface CacheOptions {
|
|
84
|
+
/**
|
|
85
|
+
* Default TTL in milliseconds
|
|
86
|
+
* @default 300000 (5 minutes)
|
|
87
|
+
*/
|
|
88
|
+
ttl?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Key prefix for cache entries
|
|
91
|
+
* @default 'grant'
|
|
92
|
+
*/
|
|
93
|
+
prefix?: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Result of an authorization check
|
|
97
|
+
*/
|
|
98
|
+
export interface AuthorizationResult {
|
|
99
|
+
/** Whether the action is authorized */
|
|
100
|
+
authorized: boolean;
|
|
101
|
+
/** Human-readable reason for the decision */
|
|
102
|
+
reason?: string;
|
|
103
|
+
/** The permission that matched (if authorized) */
|
|
104
|
+
matchedPermission?: Permission;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Options for permission queries
|
|
108
|
+
*/
|
|
109
|
+
export interface PermissionQueryOptions {
|
|
110
|
+
/** Scope to check permissions in */
|
|
111
|
+
scope?: Scope;
|
|
112
|
+
/** Whether to use cached results (default: true) */
|
|
113
|
+
useCache?: boolean;
|
|
114
|
+
/** Resource to check permissions for */
|
|
115
|
+
context?: {
|
|
116
|
+
resource?: Record<string, unknown> | null;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Permission entity
|
|
121
|
+
*/
|
|
122
|
+
export interface Permission {
|
|
123
|
+
id: string;
|
|
124
|
+
name: string;
|
|
125
|
+
description?: string | null;
|
|
126
|
+
action: string;
|
|
127
|
+
resourceId?: string | null;
|
|
128
|
+
resource?: Resource | null;
|
|
129
|
+
condition?: unknown;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Resource entity
|
|
133
|
+
*/
|
|
134
|
+
export interface Resource {
|
|
135
|
+
id: string;
|
|
136
|
+
name: string;
|
|
137
|
+
slug: string;
|
|
138
|
+
description?: string | null;
|
|
139
|
+
actions: string[];
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Error response from the API
|
|
143
|
+
*/
|
|
144
|
+
export interface ApiError {
|
|
145
|
+
error: string;
|
|
146
|
+
message?: string;
|
|
147
|
+
statusCode?: number;
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAG7C,YAAY,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAErD;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC1C,4BAA4B;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,yFAAyF;IACzF,WAAW,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAE9D;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9D;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAE5B;;;;;;;OAOG;IACH,wBAAwB,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAElD;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAErB;;;OAGG;IACH,WAAW,CAAC,EAAE,kBAAkB,CAAC;IAEjC;;OAEG;IACH,KAAK,CAAC,EAAE,YAAY,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,oFAAoF;IACpF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,uCAAuC;IACvC,UAAU,EAAE,OAAO,CAAC;IACpB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,iBAAiB,CAAC,EAAE,UAAU,CAAC;CAChC;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,oCAAoC;IACpC,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,oDAAoD;IACpD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wCAAwC;IACxC,OAAO,CAAC,EAAE;QACR,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;KAC3C,CAAC;CACH;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC3B,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB"}
|