@ulfblk/admin 0.1.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/README.md +70 -0
- package/dist/auth-provider.d.ts +10 -0
- package/dist/auth-provider.js +81 -0
- package/dist/data-provider.d.ts +13 -0
- package/dist/data-provider.js +110 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +1 -0
- package/package.json +50 -0
- package/src/auth-provider.ts +103 -0
- package/src/data-provider.ts +140 -0
- package/src/index.ts +11 -0
- package/src/types.ts +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @ulfblk/admin
|
|
2
|
+
|
|
3
|
+
React Admin DataProvider and AuthProvider for the ulfblk ecosystem.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @ulfblk/admin react-admin
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { Admin, Resource, ListGuesser, EditGuesser } from "react-admin";
|
|
15
|
+
import { BloqueClient } from "@ulfblk/api-client";
|
|
16
|
+
import { createDataProvider, createAuthProvider } from "@ulfblk/admin";
|
|
17
|
+
|
|
18
|
+
const client = new BloqueClient({ baseUrl: "http://localhost:8000" });
|
|
19
|
+
const dataProvider = createDataProvider(client);
|
|
20
|
+
const authProvider = createAuthProvider(client);
|
|
21
|
+
|
|
22
|
+
function App() {
|
|
23
|
+
return (
|
|
24
|
+
<Admin dataProvider={dataProvider} authProvider={authProvider}>
|
|
25
|
+
<Resource name="users" list={ListGuesser} edit={EditGuesser} />
|
|
26
|
+
<Resource name="orders" list={ListGuesser} edit={EditGuesser} />
|
|
27
|
+
</Admin>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- **DataProvider**: CRUD operations via BloqueClient with FastAPI-compatible query serialization (`qs` with `arrayFormat: 'repeat'`)
|
|
35
|
+
- **AuthProvider**: JWT login/logout with client-side token validation via `jwt-decode`
|
|
36
|
+
- **Tenant-aware**: BloqueClient's tenant interceptor handles tenant context automatically
|
|
37
|
+
- **Configurable**: Custom `basePath`, query serializer, login path
|
|
38
|
+
- **Error handling**: Maps HTTP errors to react-admin's `HttpError` (401 -> re-login, 403 -> access denied)
|
|
39
|
+
- **Batch operations**: `deleteMany`/`updateMany` execute in batches of 5 to avoid overwhelming the backend
|
|
40
|
+
|
|
41
|
+
## Options
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
// Custom base path
|
|
45
|
+
const dataProvider = createDataProvider(client, { basePath: "/v1" });
|
|
46
|
+
|
|
47
|
+
// Custom login endpoint
|
|
48
|
+
const authProvider = createAuthProvider(client, { loginPath: "/auth/login" });
|
|
49
|
+
|
|
50
|
+
// Multi-tenant: switch tenants dynamically
|
|
51
|
+
client.setTenantId("acme");
|
|
52
|
+
// All subsequent requests include tenant context via interceptor
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Backend Requirements
|
|
56
|
+
|
|
57
|
+
The DataProvider expects standard REST endpoints:
|
|
58
|
+
|
|
59
|
+
| Method | Endpoint | react-admin method |
|
|
60
|
+
|--------|----------|-------------------|
|
|
61
|
+
| GET | `{basePath}/{resource}` | getList, getMany |
|
|
62
|
+
| GET | `{basePath}/{resource}/{id}` | getOne |
|
|
63
|
+
| POST | `{basePath}/{resource}` | create |
|
|
64
|
+
| PUT | `{basePath}/{resource}/{id}` | update |
|
|
65
|
+
| DELETE | `{basePath}/{resource}/{id}` | delete |
|
|
66
|
+
|
|
67
|
+
List responses must match `PaginatedResponse`:
|
|
68
|
+
```json
|
|
69
|
+
{ "items": [...], "total": 100 }
|
|
70
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Admin AuthProvider using BloqueClient.
|
|
3
|
+
*
|
|
4
|
+
* Handles login/logout, token validation via jwt-decode,
|
|
5
|
+
* and permission extraction from JWT payload.
|
|
6
|
+
*/
|
|
7
|
+
import type { AuthProvider } from "react-admin";
|
|
8
|
+
import type { BloqueClient } from "@ulfblk/api-client";
|
|
9
|
+
import type { AuthProviderOptions } from "./types.js";
|
|
10
|
+
export declare function createAuthProvider(client: BloqueClient, options?: AuthProviderOptions): AuthProvider;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Admin AuthProvider using BloqueClient.
|
|
3
|
+
*
|
|
4
|
+
* Handles login/logout, token validation via jwt-decode,
|
|
5
|
+
* and permission extraction from JWT payload.
|
|
6
|
+
*/
|
|
7
|
+
import { jwtDecode } from "jwt-decode";
|
|
8
|
+
function isTokenExpired(token) {
|
|
9
|
+
try {
|
|
10
|
+
const decoded = jwtDecode(token);
|
|
11
|
+
return decoded.exp * 1000 < Date.now();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function getPayload(token) {
|
|
18
|
+
try {
|
|
19
|
+
return jwtDecode(token);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function createAuthProvider(client, options) {
|
|
26
|
+
const loginPath = options?.loginPath ?? "/auth/login";
|
|
27
|
+
let lastToken = null;
|
|
28
|
+
return {
|
|
29
|
+
login: async ({ username, password }) => {
|
|
30
|
+
const response = await client.post(loginPath, { email: username, password });
|
|
31
|
+
client.setTokens({
|
|
32
|
+
accessToken: response.access_token,
|
|
33
|
+
refreshToken: response.refresh_token,
|
|
34
|
+
});
|
|
35
|
+
lastToken = response.access_token;
|
|
36
|
+
},
|
|
37
|
+
logout: async () => {
|
|
38
|
+
client.logout();
|
|
39
|
+
lastToken = null;
|
|
40
|
+
},
|
|
41
|
+
checkAuth: async () => {
|
|
42
|
+
if (!lastToken || isTokenExpired(lastToken)) {
|
|
43
|
+
throw new Error("Token expired or missing");
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
checkError: async (error) => {
|
|
47
|
+
if (error.status === 401) {
|
|
48
|
+
client.logout();
|
|
49
|
+
lastToken = null;
|
|
50
|
+
throw new Error("Unauthorized");
|
|
51
|
+
}
|
|
52
|
+
// 403 = valid token but no permission - don't logout
|
|
53
|
+
if (error.status === 403) {
|
|
54
|
+
throw new Error("Forbidden");
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
getPermissions: async () => {
|
|
58
|
+
if (!lastToken)
|
|
59
|
+
return { roles: [], permissions: [] };
|
|
60
|
+
const payload = getPayload(lastToken);
|
|
61
|
+
if (!payload)
|
|
62
|
+
return { roles: [], permissions: [] };
|
|
63
|
+
return {
|
|
64
|
+
roles: payload.roles ?? [],
|
|
65
|
+
permissions: payload.permissions ?? [],
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
getIdentity: async () => {
|
|
69
|
+
if (!lastToken)
|
|
70
|
+
throw new Error("Not authenticated");
|
|
71
|
+
const payload = getPayload(lastToken);
|
|
72
|
+
if (!payload)
|
|
73
|
+
throw new Error("Invalid token");
|
|
74
|
+
return {
|
|
75
|
+
id: payload.sub,
|
|
76
|
+
fullName: payload.sub,
|
|
77
|
+
tenant: payload.tenant,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Admin DataProvider using BloqueClient as HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Translates react-admin CRUD calls to REST endpoints.
|
|
5
|
+
* Uses `qs` for query string serialization (FastAPI-compatible).
|
|
6
|
+
*
|
|
7
|
+
* Tenant context is handled automatically via BloqueClient's
|
|
8
|
+
* tenant interceptor (call client.setTenantId() to switch tenants).
|
|
9
|
+
*/
|
|
10
|
+
import type { DataProvider } from "react-admin";
|
|
11
|
+
import type { BloqueClient } from "@ulfblk/api-client";
|
|
12
|
+
import type { DataProviderOptions } from "./types.js";
|
|
13
|
+
export declare function createDataProvider(client: BloqueClient, options?: DataProviderOptions): DataProvider;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Admin DataProvider using BloqueClient as HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Translates react-admin CRUD calls to REST endpoints.
|
|
5
|
+
* Uses `qs` for query string serialization (FastAPI-compatible).
|
|
6
|
+
*
|
|
7
|
+
* Tenant context is handled automatically via BloqueClient's
|
|
8
|
+
* tenant interceptor (call client.setTenantId() to switch tenants).
|
|
9
|
+
*/
|
|
10
|
+
import { HttpError } from "react-admin";
|
|
11
|
+
import { stringify } from "qs";
|
|
12
|
+
const BATCH_SIZE = 5;
|
|
13
|
+
function defaultSerializer(params) {
|
|
14
|
+
return stringify(params, { arrayFormat: "repeat", skipNulls: true });
|
|
15
|
+
}
|
|
16
|
+
async function batchExecute(ids, fn) {
|
|
17
|
+
const results = [];
|
|
18
|
+
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
|
|
19
|
+
const batch = ids.slice(i, i + BATCH_SIZE);
|
|
20
|
+
const batchResults = await Promise.all(batch.map(fn));
|
|
21
|
+
results.push(...batchResults);
|
|
22
|
+
}
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
export function createDataProvider(client, options) {
|
|
26
|
+
const base = (options?.basePath ?? "/api").replace(/\/+$/, "");
|
|
27
|
+
const serialize = options?.querySerializer ?? defaultSerializer;
|
|
28
|
+
async function request(method, path, body) {
|
|
29
|
+
try {
|
|
30
|
+
switch (method) {
|
|
31
|
+
case "GET":
|
|
32
|
+
return await client.get(path);
|
|
33
|
+
case "POST":
|
|
34
|
+
return await client.post(path, body);
|
|
35
|
+
case "PUT":
|
|
36
|
+
return await client.put(path, body);
|
|
37
|
+
case "DELETE":
|
|
38
|
+
return await client.delete(path);
|
|
39
|
+
default:
|
|
40
|
+
throw new Error(`Unsupported method: ${method}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof Error && "status" in error) {
|
|
45
|
+
const e = error;
|
|
46
|
+
throw new HttpError(e.message, e.status, e.body);
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
getList: async (resource, params) => {
|
|
53
|
+
const { page, perPage } = params.pagination ?? { page: 1, perPage: 20 };
|
|
54
|
+
const { field, order } = params.sort ?? { field: "id", order: "ASC" };
|
|
55
|
+
const query = serialize({
|
|
56
|
+
page,
|
|
57
|
+
size: perPage,
|
|
58
|
+
sort: field,
|
|
59
|
+
order,
|
|
60
|
+
...params.filter,
|
|
61
|
+
});
|
|
62
|
+
const url = `${base}/${resource}?${query}`;
|
|
63
|
+
const response = await request("GET", url);
|
|
64
|
+
return { data: response.items, total: response.total };
|
|
65
|
+
},
|
|
66
|
+
getOne: async (resource, params) => {
|
|
67
|
+
const data = await request("GET", `${base}/${resource}/${params.id}`);
|
|
68
|
+
return { data };
|
|
69
|
+
},
|
|
70
|
+
getMany: async (resource, params) => {
|
|
71
|
+
const query = serialize({ id: params.ids });
|
|
72
|
+
const response = await request("GET", `${base}/${resource}?${query}`);
|
|
73
|
+
return { data: response.items };
|
|
74
|
+
},
|
|
75
|
+
getManyReference: async (resource, params) => {
|
|
76
|
+
const { page, perPage } = params.pagination ?? { page: 1, perPage: 20 };
|
|
77
|
+
const { field, order } = params.sort ?? { field: "id", order: "ASC" };
|
|
78
|
+
const query = serialize({
|
|
79
|
+
page,
|
|
80
|
+
size: perPage,
|
|
81
|
+
sort: field,
|
|
82
|
+
order,
|
|
83
|
+
[params.target]: params.id,
|
|
84
|
+
...params.filter,
|
|
85
|
+
});
|
|
86
|
+
const response = await request("GET", `${base}/${resource}?${query}`);
|
|
87
|
+
return { data: response.items, total: response.total };
|
|
88
|
+
},
|
|
89
|
+
create: async (resource, params) => {
|
|
90
|
+
const data = await request("POST", `${base}/${resource}`, params.data);
|
|
91
|
+
return { data };
|
|
92
|
+
},
|
|
93
|
+
update: async (resource, params) => {
|
|
94
|
+
const data = await request("PUT", `${base}/${resource}/${params.id}`, params.data);
|
|
95
|
+
return { data };
|
|
96
|
+
},
|
|
97
|
+
delete: async (resource, params) => {
|
|
98
|
+
const data = await request("DELETE", `${base}/${resource}/${params.id}`);
|
|
99
|
+
return { data };
|
|
100
|
+
},
|
|
101
|
+
deleteMany: async (resource, params) => {
|
|
102
|
+
await batchExecute(params.ids, (id) => request("DELETE", `${base}/${resource}/${id}`));
|
|
103
|
+
return { data: params.ids };
|
|
104
|
+
},
|
|
105
|
+
updateMany: async (resource, params) => {
|
|
106
|
+
await batchExecute(params.ids, (id) => request("PUT", `${base}/${resource}/${id}`, params.data));
|
|
107
|
+
return { data: params.ids };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ulfblk/admin - React Admin providers for the ulfblk ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* Provides DataProvider and AuthProvider that use BloqueClient
|
|
5
|
+
* as the HTTP transport layer, with automatic tenant context,
|
|
6
|
+
* JWT auth, and FastAPI-compatible query serialization.
|
|
7
|
+
*/
|
|
8
|
+
export { createDataProvider } from "./data-provider.js";
|
|
9
|
+
export { createAuthProvider } from "./auth-provider.js";
|
|
10
|
+
export type { DataProviderOptions, AuthProviderOptions, ListResponse } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ulfblk/admin - React Admin providers for the ulfblk ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* Provides DataProvider and AuthProvider that use BloqueClient
|
|
5
|
+
* as the HTTP transport layer, with automatic tenant context,
|
|
6
|
+
* JWT auth, and FastAPI-compatible query serialization.
|
|
7
|
+
*/
|
|
8
|
+
export { createDataProvider } from "./data-provider.js";
|
|
9
|
+
export { createAuthProvider } from "./auth-provider.js";
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration options for the ulfblk DataProvider.
|
|
3
|
+
*/
|
|
4
|
+
export interface DataProviderOptions {
|
|
5
|
+
/** Base path for API endpoints. Default: "/api" */
|
|
6
|
+
basePath?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Custom query string serializer.
|
|
9
|
+
* Default uses `qs` with arrayFormat: 'repeat' (FastAPI-compatible).
|
|
10
|
+
*/
|
|
11
|
+
querySerializer?: (params: Record<string, unknown>) => string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Configuration options for the ulfblk AuthProvider.
|
|
15
|
+
*/
|
|
16
|
+
export interface AuthProviderOptions {
|
|
17
|
+
/** Path for the login endpoint. Default: "/auth/login" */
|
|
18
|
+
loginPath?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Expected response format from list endpoints.
|
|
22
|
+
* Must match PaginatedResponse from the Python backend.
|
|
23
|
+
*/
|
|
24
|
+
export interface ListResponse<T = Record<string, unknown>> {
|
|
25
|
+
items: T[];
|
|
26
|
+
total: number;
|
|
27
|
+
page?: number;
|
|
28
|
+
page_size?: number;
|
|
29
|
+
pages?: number;
|
|
30
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ulfblk/admin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React Admin DataProvider and AuthProvider for the ulfblk ecosystem",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/abelardodiaz/web25-991-bloques-reciclables",
|
|
9
|
+
"directory": "packages/typescript/ulfblk-admin"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": ["dist", "src", "README.md"],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"lint": "biome check src/",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@ulfblk/api-client": "^0.1.0",
|
|
28
|
+
"@ulfblk/types": "^0.1.0",
|
|
29
|
+
"qs": "^6.14.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/qs": "^6.9.0",
|
|
33
|
+
"jwt-decode": "^4.0.0",
|
|
34
|
+
"react-admin": "^5.0.0",
|
|
35
|
+
"react": "^19.0.0",
|
|
36
|
+
"react-dom": "^19.0.0",
|
|
37
|
+
"typescript": "^5.7.0",
|
|
38
|
+
"vitest": "^3.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"react-admin": "^5.0.0",
|
|
42
|
+
"react": "^18 || ^19"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"react-dom": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"keywords": ["react-admin", "admin", "data-provider", "auth-provider", "ulfblk", "saas"]
|
|
50
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Admin AuthProvider using BloqueClient.
|
|
3
|
+
*
|
|
4
|
+
* Handles login/logout, token validation via jwt-decode,
|
|
5
|
+
* and permission extraction from JWT payload.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AuthProvider } from "react-admin";
|
|
9
|
+
import type { BloqueClient } from "@ulfblk/api-client";
|
|
10
|
+
import { jwtDecode } from "jwt-decode";
|
|
11
|
+
import type { AuthProviderOptions } from "./types.js";
|
|
12
|
+
|
|
13
|
+
interface JwtPayload {
|
|
14
|
+
sub: string;
|
|
15
|
+
tenant: string;
|
|
16
|
+
exp: number;
|
|
17
|
+
type: string;
|
|
18
|
+
roles?: string[];
|
|
19
|
+
permissions?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isTokenExpired(token: string): boolean {
|
|
23
|
+
try {
|
|
24
|
+
const decoded = jwtDecode<JwtPayload>(token);
|
|
25
|
+
return decoded.exp * 1000 < Date.now();
|
|
26
|
+
} catch {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getPayload(token: string): JwtPayload | null {
|
|
32
|
+
try {
|
|
33
|
+
return jwtDecode<JwtPayload>(token);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createAuthProvider(
|
|
40
|
+
client: BloqueClient,
|
|
41
|
+
options?: AuthProviderOptions,
|
|
42
|
+
): AuthProvider {
|
|
43
|
+
const loginPath = options?.loginPath ?? "/auth/login";
|
|
44
|
+
let lastToken: string | null = null;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
login: async ({ username, password }: { username: string; password: string }) => {
|
|
48
|
+
const response = await client.post<{ access_token: string; refresh_token: string }>(
|
|
49
|
+
loginPath,
|
|
50
|
+
{ email: username, password },
|
|
51
|
+
);
|
|
52
|
+
client.setTokens({
|
|
53
|
+
accessToken: response.access_token,
|
|
54
|
+
refreshToken: response.refresh_token,
|
|
55
|
+
});
|
|
56
|
+
lastToken = response.access_token;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
logout: async () => {
|
|
60
|
+
client.logout();
|
|
61
|
+
lastToken = null;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
checkAuth: async () => {
|
|
65
|
+
if (!lastToken || isTokenExpired(lastToken)) {
|
|
66
|
+
throw new Error("Token expired or missing");
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
checkError: async (error: { status?: number }) => {
|
|
71
|
+
if (error.status === 401) {
|
|
72
|
+
client.logout();
|
|
73
|
+
lastToken = null;
|
|
74
|
+
throw new Error("Unauthorized");
|
|
75
|
+
}
|
|
76
|
+
// 403 = valid token but no permission - don't logout
|
|
77
|
+
if (error.status === 403) {
|
|
78
|
+
throw new Error("Forbidden");
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
getPermissions: async () => {
|
|
83
|
+
if (!lastToken) return { roles: [], permissions: [] };
|
|
84
|
+
const payload = getPayload(lastToken);
|
|
85
|
+
if (!payload) return { roles: [], permissions: [] };
|
|
86
|
+
return {
|
|
87
|
+
roles: payload.roles ?? [],
|
|
88
|
+
permissions: payload.permissions ?? [],
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
getIdentity: async () => {
|
|
93
|
+
if (!lastToken) throw new Error("Not authenticated");
|
|
94
|
+
const payload = getPayload(lastToken);
|
|
95
|
+
if (!payload) throw new Error("Invalid token");
|
|
96
|
+
return {
|
|
97
|
+
id: payload.sub,
|
|
98
|
+
fullName: payload.sub,
|
|
99
|
+
tenant: payload.tenant,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Admin DataProvider using BloqueClient as HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Translates react-admin CRUD calls to REST endpoints.
|
|
5
|
+
* Uses `qs` for query string serialization (FastAPI-compatible).
|
|
6
|
+
*
|
|
7
|
+
* Tenant context is handled automatically via BloqueClient's
|
|
8
|
+
* tenant interceptor (call client.setTenantId() to switch tenants).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { DataProvider } from "react-admin";
|
|
12
|
+
import { HttpError } from "react-admin";
|
|
13
|
+
import { stringify } from "qs";
|
|
14
|
+
import type { BloqueClient } from "@ulfblk/api-client";
|
|
15
|
+
import type { DataProviderOptions, ListResponse } from "./types.js";
|
|
16
|
+
|
|
17
|
+
const BATCH_SIZE = 5;
|
|
18
|
+
|
|
19
|
+
function defaultSerializer(params: Record<string, unknown>): string {
|
|
20
|
+
return stringify(params, { arrayFormat: "repeat", skipNulls: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function batchExecute<T>(
|
|
24
|
+
ids: (string | number)[],
|
|
25
|
+
fn: (id: string | number) => Promise<T>,
|
|
26
|
+
): Promise<T[]> {
|
|
27
|
+
const results: T[] = [];
|
|
28
|
+
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
|
|
29
|
+
const batch = ids.slice(i, i + BATCH_SIZE);
|
|
30
|
+
const batchResults = await Promise.all(batch.map(fn));
|
|
31
|
+
results.push(...batchResults);
|
|
32
|
+
}
|
|
33
|
+
return results;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createDataProvider(
|
|
37
|
+
client: BloqueClient,
|
|
38
|
+
options?: DataProviderOptions,
|
|
39
|
+
): DataProvider {
|
|
40
|
+
const base = (options?.basePath ?? "/api").replace(/\/+$/, "");
|
|
41
|
+
const serialize = options?.querySerializer ?? defaultSerializer;
|
|
42
|
+
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-admin generics are complex
|
|
44
|
+
type AnyRecord = any;
|
|
45
|
+
|
|
46
|
+
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
47
|
+
try {
|
|
48
|
+
switch (method) {
|
|
49
|
+
case "GET":
|
|
50
|
+
return await client.get<T>(path);
|
|
51
|
+
case "POST":
|
|
52
|
+
return await client.post<T>(path, body);
|
|
53
|
+
case "PUT":
|
|
54
|
+
return await client.put<T>(path, body);
|
|
55
|
+
case "DELETE":
|
|
56
|
+
return await client.delete<T>(path);
|
|
57
|
+
default:
|
|
58
|
+
throw new Error(`Unsupported method: ${method}`);
|
|
59
|
+
}
|
|
60
|
+
} catch (error: unknown) {
|
|
61
|
+
if (error instanceof Error && "status" in error) {
|
|
62
|
+
const e = error as Error & { status: number; body?: unknown };
|
|
63
|
+
throw new HttpError(e.message, e.status, e.body);
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
getList: async (resource: string, params: any) => {
|
|
71
|
+
const { page, perPage } = params.pagination ?? { page: 1, perPage: 20 };
|
|
72
|
+
const { field, order } = params.sort ?? { field: "id", order: "ASC" };
|
|
73
|
+
const query = serialize({
|
|
74
|
+
page,
|
|
75
|
+
size: perPage,
|
|
76
|
+
sort: field,
|
|
77
|
+
order,
|
|
78
|
+
...params.filter,
|
|
79
|
+
});
|
|
80
|
+
const url = `${base}/${resource}?${query}`;
|
|
81
|
+
const response = await request<AnyRecord>("GET", url);
|
|
82
|
+
return { data: response.items, total: response.total };
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
getOne: async (resource: string, params: any) => {
|
|
86
|
+
const data = await request<AnyRecord>("GET", `${base}/${resource}/${params.id}`);
|
|
87
|
+
return { data };
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
getMany: async (resource: string, params: any) => {
|
|
91
|
+
const query = serialize({ id: params.ids });
|
|
92
|
+
const response = await request<AnyRecord>("GET", `${base}/${resource}?${query}`);
|
|
93
|
+
return { data: response.items };
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
getManyReference: async (resource: string, params: any) => {
|
|
97
|
+
const { page, perPage } = params.pagination ?? { page: 1, perPage: 20 };
|
|
98
|
+
const { field, order } = params.sort ?? { field: "id", order: "ASC" };
|
|
99
|
+
const query = serialize({
|
|
100
|
+
page,
|
|
101
|
+
size: perPage,
|
|
102
|
+
sort: field,
|
|
103
|
+
order,
|
|
104
|
+
[params.target]: params.id,
|
|
105
|
+
...params.filter,
|
|
106
|
+
});
|
|
107
|
+
const response = await request<AnyRecord>("GET", `${base}/${resource}?${query}`);
|
|
108
|
+
return { data: response.items, total: response.total };
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
create: async (resource: string, params: any) => {
|
|
112
|
+
const data = await request<AnyRecord>("POST", `${base}/${resource}`, params.data);
|
|
113
|
+
return { data };
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
update: async (resource: string, params: any) => {
|
|
117
|
+
const data = await request<AnyRecord>("PUT", `${base}/${resource}/${params.id}`, params.data);
|
|
118
|
+
return { data };
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
delete: async (resource: string, params: any) => {
|
|
122
|
+
const data = await request<AnyRecord>("DELETE", `${base}/${resource}/${params.id}`);
|
|
123
|
+
return { data };
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
deleteMany: async (resource: string, params: any) => {
|
|
127
|
+
await batchExecute(params.ids, (id) =>
|
|
128
|
+
request("DELETE", `${base}/${resource}/${id}`),
|
|
129
|
+
);
|
|
130
|
+
return { data: params.ids };
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
updateMany: async (resource: string, params: any) => {
|
|
134
|
+
await batchExecute(params.ids, (id) =>
|
|
135
|
+
request("PUT", `${base}/${resource}/${id}`, params.data),
|
|
136
|
+
);
|
|
137
|
+
return { data: params.ids };
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ulfblk/admin - React Admin providers for the ulfblk ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* Provides DataProvider and AuthProvider that use BloqueClient
|
|
5
|
+
* as the HTTP transport layer, with automatic tenant context,
|
|
6
|
+
* JWT auth, and FastAPI-compatible query serialization.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { createDataProvider } from "./data-provider.js";
|
|
10
|
+
export { createAuthProvider } from "./auth-provider.js";
|
|
11
|
+
export type { DataProviderOptions, AuthProviderOptions, ListResponse } from "./types.js";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration options for the ulfblk DataProvider.
|
|
3
|
+
*/
|
|
4
|
+
export interface DataProviderOptions {
|
|
5
|
+
/** Base path for API endpoints. Default: "/api" */
|
|
6
|
+
basePath?: string;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Custom query string serializer.
|
|
10
|
+
* Default uses `qs` with arrayFormat: 'repeat' (FastAPI-compatible).
|
|
11
|
+
*/
|
|
12
|
+
querySerializer?: (params: Record<string, unknown>) => string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Configuration options for the ulfblk AuthProvider.
|
|
17
|
+
*/
|
|
18
|
+
export interface AuthProviderOptions {
|
|
19
|
+
/** Path for the login endpoint. Default: "/auth/login" */
|
|
20
|
+
loginPath?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Expected response format from list endpoints.
|
|
25
|
+
* Must match PaginatedResponse from the Python backend.
|
|
26
|
+
*/
|
|
27
|
+
export interface ListResponse<T = Record<string, unknown>> {
|
|
28
|
+
items: T[];
|
|
29
|
+
total: number;
|
|
30
|
+
page?: number;
|
|
31
|
+
page_size?: number;
|
|
32
|
+
pages?: number;
|
|
33
|
+
}
|