@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alejandro Heredia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,417 @@
1
+ # @grantjs/client
2
+
3
+ Browser SDK for Grant authorization platform. Provides a lightweight client for permission checks with React hooks and components for conditional UI rendering.
4
+
5
+ **Documentation:** [Client SDK](https://github.com/logusgraphics/grant/blob/main/docs/integration/client-sdk.md) in the official docs.
6
+
7
+ ## Features
8
+
9
+ - **REST-based API** - Uses native `fetch`, no GraphQL client required
10
+ - **Automatic token refresh** - Handles 401 errors with token refresh and retry
11
+ - **Built-in caching** - Configurable TTL-based cache to minimize API calls
12
+ - **Multi-tenant support** - Dynamic scope switching for session tokens
13
+ - **React integration** - Hooks and components for declarative permission checks
14
+ - **TypeScript** - Full type safety with types from `@grantjs/schema`
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @grantjs/client
20
+ # or
21
+ pnpm add @grantjs/client
22
+ # or
23
+ yarn add @grantjs/client
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ ### 1. Create the Client
29
+
30
+ ```typescript
31
+ import { GrantClient } from '@grantjs/client';
32
+
33
+ const grant = new GrantClient({
34
+ apiUrl: 'https://api.your-app.com',
35
+
36
+ getAccessToken: () => localStorage.getItem('accessToken'),
37
+
38
+ // Cookie-based refresh: your callback calls POST /api/auth/refresh with credentials: 'include',
39
+ // then updates app token storage. Refresh token is in HttpOnly cookie; response body has only accessToken.
40
+ onRefreshWithCredentials: async () => {
41
+ const res = await fetch('https://api.your-app.com/api/auth/refresh', {
42
+ method: 'POST',
43
+ credentials: 'include',
44
+ });
45
+ if (!res.ok) return false;
46
+ const { data } = await res.json();
47
+ if (data?.accessToken) {
48
+ localStorage.setItem('accessToken', data.accessToken);
49
+ return true;
50
+ }
51
+ return false;
52
+ },
53
+
54
+ onTokenRefresh: (tokens) => {
55
+ localStorage.setItem('accessToken', tokens.accessToken);
56
+ // tokens.refreshToken may be undefined when using cookie-based refresh
57
+ },
58
+
59
+ onUnauthorized: () => {
60
+ window.location.href = '/login';
61
+ },
62
+ });
63
+
64
+ // Check permission
65
+ const canEdit = await grant.can('Document', 'Update');
66
+ ```
67
+
68
+ ### 2. React Setup
69
+
70
+ Wrap your app with `GrantProvider` and integrate with your auth store:
71
+
72
+ ```tsx
73
+ 'use client';
74
+
75
+ import { useMemo } from 'react';
76
+ import { GrantProvider, type GrantClientConfig } from '@grantjs/client/react';
77
+ import { useAuthStore } from '@/stores/auth.store';
78
+
79
+ export function AppProviders({ children }: { children: React.ReactNode }) {
80
+ const config = useMemo<GrantClientConfig>(
81
+ () => ({
82
+ apiUrl: process.env.NEXT_PUBLIC_API_URL!,
83
+
84
+ getAccessToken: () => useAuthStore.getState().accessToken,
85
+
86
+ // Cookie-based refresh: POST /api/auth/refresh with credentials: 'include', then update store
87
+ onRefreshWithCredentials: async () => {
88
+ const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refresh`, {
89
+ method: 'POST',
90
+ credentials: 'include',
91
+ });
92
+ if (!res.ok) return false;
93
+ const { data } = await res.json();
94
+ if (data?.accessToken) {
95
+ useAuthStore.getState().setAccessToken(data.accessToken);
96
+ return true;
97
+ }
98
+ return false;
99
+ },
100
+
101
+ onTokenRefresh: (tokens) => {
102
+ useAuthStore.getState().setAccessToken(tokens.accessToken);
103
+ },
104
+
105
+ onUnauthorized: () => {
106
+ useAuthStore.getState().logout();
107
+ if (typeof window !== 'undefined') {
108
+ window.location.href = '/auth/login';
109
+ }
110
+ },
111
+
112
+ cache: {
113
+ ttl: 5 * 60 * 1000, // 5 minutes
114
+ prefix: 'grant',
115
+ },
116
+ }),
117
+ []
118
+ );
119
+
120
+ return <GrantProvider config={config}>{children}</GrantProvider>;
121
+ }
122
+ ```
123
+
124
+ ### 3. Use useGrant with Scopes
125
+
126
+ For multi-tenant apps, pass the scope to check permissions in a specific context:
127
+
128
+ ```tsx
129
+ 'use client';
130
+
131
+ import { useGrant } from '@grantjs/client/react';
132
+ import { Tenant } from '@grantjs/schema';
133
+
134
+ interface OrganizationActionsProps {
135
+ organization: { id: string; name: string };
136
+ }
137
+
138
+ export function OrganizationActions({ organization }: OrganizationActionsProps) {
139
+ // Scope permissions to this specific organization
140
+ const scope = { tenant: Tenant.Organization, id: organization.id };
141
+
142
+ // Check permissions - these call POST /api/auth/is-authorized
143
+ const canUpdate = useGrant('Organization', 'Update', { scope });
144
+ const canDelete = useGrant('Organization', 'Delete', { scope });
145
+
146
+ // Hide component entirely if user has no permissions
147
+ if (!canUpdate && !canDelete) {
148
+ return null;
149
+ }
150
+
151
+ return (
152
+ <div>
153
+ {canUpdate && <button>Edit</button>}
154
+ {canDelete && <button>Delete</button>}
155
+ </div>
156
+ );
157
+ }
158
+ ```
159
+
160
+ ### 4. Use Components
161
+
162
+ ```tsx
163
+ import { GrantGate } from '@grantjs/client/react';
164
+ import { Tenant } from '@grantjs/schema';
165
+
166
+ function Dashboard({ projectId }: { projectId: string }) {
167
+ const scope = { tenant: Tenant.Organization, id: projectId };
168
+
169
+ return (
170
+ <div>
171
+ {/* Hide if no permission */}
172
+ <GrantGate resource="Analytics" action="Read" scope={scope}>
173
+ <AnalyticsWidget />
174
+ </GrantGate>
175
+
176
+ {/* Show fallback if denied */}
177
+ <GrantGate
178
+ resource="Settings"
179
+ action="Update"
180
+ scope={scope}
181
+ fallback={<p>Contact admin for access</p>}
182
+ >
183
+ <SettingsPanel />
184
+ </GrantGate>
185
+
186
+ {/* With loading state */}
187
+ <GrantGate resource="Report" action="Create" scope={scope} loading={<Spinner />}>
188
+ <ExportButton />
189
+ </GrantGate>
190
+ </div>
191
+ );
192
+ }
193
+ ```
194
+
195
+ ## API Reference
196
+
197
+ ### GrantClient
198
+
199
+ ```typescript
200
+ const grant = new GrantClient(config: GrantClientConfig);
201
+ ```
202
+
203
+ #### Configuration
204
+
205
+ ```typescript
206
+ interface GrantClientConfig {
207
+ apiUrl: string;
208
+
209
+ getAccessToken?: () => string | null | Promise<string | null>;
210
+ /** Called after cookie-based refresh; use to update app token storage. `tokens.refreshToken` may be undefined. */
211
+ onTokenRefresh?: (tokens: AuthTokens) => void | Promise<void>;
212
+ onUnauthorized?: () => void;
213
+ /** Cookie-based refresh on 401: call POST /api/auth/refresh with credentials: 'include', update token, return true on success. */
214
+ onRefreshWithCredentials?: () => Promise<boolean>;
215
+
216
+ fetch?: typeof fetch;
217
+ credentials?: RequestCredentials; // default: 'include'
218
+ cache?: { ttl?: number; prefix?: string };
219
+ }
220
+ ```
221
+
222
+ #### Methods
223
+
224
+ ```typescript
225
+ // Permission checks
226
+ grant.can(resource, action, options?): Promise<boolean>
227
+ grant.hasPermission(resource, action, options?): Promise<boolean> // Alias
228
+ grant.isAuthorized(resource, action, options?): Promise<AuthorizationResult>
229
+
230
+ // Cache management
231
+ grant.clearCache(): void
232
+ grant.clearScopeCache(scope?): void
233
+ ```
234
+
235
+ ### React Hooks
236
+
237
+ #### `useGrant(resource, action, options?)`
238
+
239
+ Returns a boolean by default, or an object with `isGranted` and `isLoading` when `returnLoading: true`.
240
+
241
+ **Default (boolean):**
242
+
243
+ ```tsx
244
+ import { useGrant } from '@grantjs/client/react';
245
+ import { Tenant } from '@grantjs/schema';
246
+
247
+ const canEdit = useGrant('Document', 'Update', {
248
+ scope: { tenant: Tenant.Organization, id: orgId },
249
+ });
250
+
251
+ return <div>{canEdit && <EditButton />}</div>;
252
+ ```
253
+
254
+ **With loading state:**
255
+
256
+ ```tsx
257
+ const { isGranted, isLoading } = useGrant('Document', 'Update', {
258
+ scope: { tenant: Tenant.Organization, id: orgId },
259
+ returnLoading: true,
260
+ });
261
+
262
+ if (isLoading) return <Spinner />;
263
+ if (!isGranted) return null;
264
+
265
+ return <EditButton />;
266
+ ```
267
+
268
+ #### Hook Options
269
+
270
+ ```typescript
271
+ interface UseGrantOptions {
272
+ scope?: Scope; // Multi-tenant scope override
273
+ enabled?: boolean; // Skip check if false (default: true)
274
+ useCache?: boolean; // Use cached result (default: true)
275
+ returnLoading?: boolean; // Return object with isGranted and isLoading (default: false)
276
+ }
277
+
278
+ interface UseGrantResult {
279
+ isGranted: boolean; // Whether the user is granted permission
280
+ isLoading: boolean; // Whether the permission check is loading
281
+ }
282
+ ```
283
+
284
+ ### React Components
285
+
286
+ #### `<GrantGate>`
287
+
288
+ ```tsx
289
+ <GrantGate
290
+ resource="Resource"
291
+ action="Action"
292
+ scope={{ tenant: Tenant.Organization, id: '...' }} // Optional
293
+ fallback={<FallbackComponent />} // Optional
294
+ loading={<LoadingSpinner />} // Optional
295
+ >
296
+ <ProtectedContent />
297
+ </GrantGate>
298
+ ```
299
+
300
+ ## Multi-Tenant Scope Override
301
+
302
+ The Grant platform supports multi-tenant authorization with dynamic scope switching:
303
+
304
+ - **Session tokens**: Can override scope at request time (for users switching between organizations)
305
+ - **API key tokens**: Use their embedded scope (cannot be overridden)
306
+
307
+ ```tsx
308
+ import { useGrant } from '@grantjs/client/react';
309
+ import { Tenant } from '@grantjs/schema';
310
+
311
+ // User is viewing Organization A
312
+ const scopeA = { tenant: Tenant.Organization, id: 'org-a-id' };
313
+ const canEditA = useGrant('Project', 'Update', { scope: scopeA });
314
+
315
+ // User switches to Organization B
316
+ const scopeB = { tenant: Tenant.Organization, id: 'org-b-id' };
317
+ const canEditB = useGrant('Project', 'Update', { scope: scopeB });
318
+ ```
319
+
320
+ Available tenant types (from `@grantjs/schema`):
321
+
322
+ ```typescript
323
+ enum Tenant {
324
+ System = 'system',
325
+ Account = 'account',
326
+ Organization = 'organization',
327
+ AccountProject = 'accountProject',
328
+ OrganizationProject = 'organizationProject',
329
+ ProjectUser = 'projectUser',
330
+ // ... and more
331
+ }
332
+ ```
333
+
334
+ ## Caching
335
+
336
+ The client caches permission results by default (5 minute TTL). You can:
337
+
338
+ ```typescript
339
+ // Configure TTL
340
+ const grant = new GrantClient({
341
+ apiUrl: '...',
342
+ cache: { ttl: 10 * 60 * 1000 }, // 10 minutes
343
+ });
344
+
345
+ // Bypass cache for a specific check
346
+ const fresh = await grant.can('Resource', 'Action', { useCache: false });
347
+
348
+ // Clear all cache
349
+ grant.clearCache();
350
+
351
+ // Clear cache for a specific scope
352
+ grant.clearScopeCache({ tenant: Tenant.Organization, id: orgId });
353
+ ```
354
+
355
+ ## Authentication Flow
356
+
357
+ 1. **Access token** is sent via `Authorization: Bearer <token>` (when `getAccessToken` is provided).
358
+ 2. **Cookies** are included by default (`credentials: 'include'`).
359
+ 3. On **401**, the client uses **cookie-based refresh only** (no body-based refresh for security):
360
+ - If `onRefreshWithCredentials` is set, it is called. Your callback should call `POST /api/auth/refresh` with `credentials: 'include'`, read the new `accessToken` from the response body, update your storage, and return `true` on success.
361
+ - On success the client retries the original request; you may also use `onTokenRefresh` to sync token state.
362
+ - On failure or if `onRefreshWithCredentials` is not set, `onUnauthorized()` is called (e.g. redirect to login).
363
+ - The refresh token lives in an HttpOnly cookie; the API returns only `accessToken` in the refresh response body.
364
+
365
+ ## Development Notes
366
+
367
+ ### React Strict Mode
368
+
369
+ In development with React Strict Mode enabled (default in Next.js 13+), you'll see **2 API calls** per permission check. This is expected behavior:
370
+
371
+ 1. Component mounts → effect runs → API call #1
372
+ 2. Strict Mode unmounts component
373
+ 3. Component remounts → effect runs → API call #2
374
+
375
+ This only happens in development. Production builds make a single call.
376
+
377
+ ### Stable Scope References
378
+
379
+ The hooks automatically handle scope object reference changes. You don't need to memoize the scope object:
380
+
381
+ ```tsx
382
+ // This is fine - hooks compare by value, not reference
383
+ const scope = { tenant: Tenant.Organization, id: organization.id };
384
+ const canEdit = useGrant('Resource', 'Action', { scope });
385
+ ```
386
+
387
+ ## TypeScript
388
+
389
+ Full type definitions are included. Import types from the package or re-exported from `@grantjs/schema`:
390
+
391
+ ```typescript
392
+ import type {
393
+ GrantClientConfig,
394
+ AuthorizationResult,
395
+ Permission,
396
+ Resource,
397
+ Scope,
398
+ Tenant,
399
+ } from '@grantjs/client';
400
+
401
+ // Or import Tenant enum directly from schema
402
+ import { Tenant } from '@grantjs/schema';
403
+ ```
404
+
405
+ ## Contributing
406
+
407
+ Contributions are welcome! Please see the [main repository](https://github.com/logusgraphics/grant) for contribution guidelines.
408
+
409
+ ## Support
410
+
411
+ - **Documentation**: See the [main Grant documentation](https://github.com/logusgraphics/grant)
412
+ - **Issues**: Report bugs or request features on [GitHub Issues](https://github.com/logusgraphics/grant/issues)
413
+ - **Email**: ale@logus.graphics
414
+
415
+ ## License
416
+
417
+ MIT
@@ -0,0 +1,75 @@
1
+ import { GrantClientConfig, AuthorizationResult, PermissionQueryOptions, Scope, SignInWithProjectAppOptions } from './types';
2
+ /**
3
+ * Grant Client for browser applications
4
+ *
5
+ * Makes HTTP requests to the Grant API to check permissions
6
+ * and retrieve authorization data. Supports both token-based
7
+ * and cookie-based authentication with automatic token refresh.
8
+ */
9
+ export declare class GrantClient {
10
+ private config;
11
+ private cache;
12
+ private defaultTtl;
13
+ constructor(config: GrantClientConfig);
14
+ /**
15
+ * Check if the current user has a specific permission
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const canEdit = await grant.can('document', 'update');
20
+ * if (canEdit) {
21
+ * // Show edit button
22
+ * }
23
+ * ```
24
+ */
25
+ can(resource: string, action: string, options?: PermissionQueryOptions): Promise<boolean>;
26
+ /**
27
+ * Alias for `can` - check if user has permission
28
+ */
29
+ hasPermission(resource: string, action: string, options?: PermissionQueryOptions): Promise<boolean>;
30
+ /**
31
+ * Start project-app OAuth flow (redirect only).
32
+ * Navigates the current window to the Grant OAuth entry page; after sign-in and consent,
33
+ * the user is redirected to the app's `redirect_uri` with token in the URL fragment.
34
+ *
35
+ * Requires `config.frontendUrl` and `redirectUri`.
36
+ */
37
+ signInWithProjectApp(options: SignInWithProjectAppOptions): Promise<void>;
38
+ /**
39
+ * Check authorization with full result details
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const result = await grant.isAuthorized('document', 'update');
44
+ * if (!result.authorized) {
45
+ * console.log('Denied:', result.reason);
46
+ * }
47
+ * ```
48
+ */
49
+ isAuthorized(resource: string, action: string, options?: PermissionQueryOptions): Promise<AuthorizationResult>;
50
+ /**
51
+ * Clear all cached data
52
+ */
53
+ clearCache(): void;
54
+ /**
55
+ * Clear cached data for a specific scope
56
+ */
57
+ clearScopeCache(scope?: Scope): void;
58
+ /**
59
+ * Make an authenticated fetch request with automatic token refresh on 401
60
+ */
61
+ private fetchWithAuth;
62
+ /**
63
+ * Perform the actual fetch request
64
+ */
65
+ private doFetch;
66
+ /**
67
+ * Get the current access token
68
+ */
69
+ private getToken;
70
+ private buildUrl;
71
+ private getCacheKey;
72
+ private getFromCache;
73
+ private setCache;
74
+ }
75
+ //# sourceMappingURL=grant-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grant-client.d.ts","sourceRoot":"","sources":["../src/grant-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,mBAAmB,EACnB,sBAAsB,EACtB,KAAK,EACL,2BAA2B,EAC5B,MAAM,SAAS,CAAC;AAQjB;;;;;;GAMG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,MAAM,CAAkE;IAChF,OAAO,CAAC,KAAK,CAA8D;IAC3E,OAAO,CAAC,UAAU,CAAS;gBAEf,MAAM,EAAE,iBAAiB;IASrC;;;;;;;;;;OAUG;IACG,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,sBAAsB,GAAG,OAAO,CAAC,OAAO,CAAC;IAK/F;;OAEG;IACG,aAAa,CACjB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,OAAO,CAAC;IAQnB;;;;;;OAMG;IACG,oBAAoB,CAAC,OAAO,EAAE,2BAA2B,GAAG,OAAO,CAAC,IAAI,CAAC;IAyB/E;;;;;;;;;;OAUG;IACG,YAAY,CAChB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,mBAAmB,CAAC;IA8D/B;;OAEG;IACH,UAAU,IAAI,IAAI;IAIlB;;OAEG;IACH,eAAe,CAAC,KAAK,CAAC,EAAE,KAAK,GAAG,IAAI;IAapC;;OAEG;YACW,aAAa;IAqB3B;;OAEG;YACW,OAAO;IAuBrB;;OAEG;YACW,QAAQ;IAYtB,OAAO,CAAC,QAAQ;IAQhB,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,YAAY;IAYpB,OAAO,CAAC,QAAQ;CAMjB"}