@mapomodule/core 1.0.0 → 2.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/module.d.mts +10 -0
- package/dist/module.d.ts +10 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +73 -0
- package/dist/runtime/__tests__/plugins/00.fetch.spec.d.ts +2 -0
- package/dist/runtime/__tests__/plugins/00.fetch.spec.d.ts.map +1 -0
- package/dist/runtime/api/crud.d.ts +23 -0
- package/dist/runtime/api/crud.d.ts.map +1 -0
- package/dist/runtime/api/crud.js +74 -0
- package/dist/runtime/api/multipart.d.ts +15 -0
- package/dist/runtime/api/multipart.d.ts.map +1 -0
- package/dist/runtime/api/multipart.js +31 -0
- package/dist/runtime/auth/useCanAccessRoute.d.ts +12 -0
- package/dist/runtime/auth/useCanAccessRoute.d.ts.map +1 -0
- package/dist/runtime/auth/useCanAccessRoute.js +28 -0
- package/dist/runtime/auth/useMapoAuth.d.ts +28 -0
- package/dist/runtime/auth/useMapoAuth.d.ts.map +1 -0
- package/dist/runtime/auth/useMapoAuth.js +38 -0
- package/dist/runtime/composables/useMapo.d.ts +48 -0
- package/dist/runtime/composables/useMapo.d.ts.map +1 -0
- package/dist/runtime/composables/useMapo.js +15 -0
- package/dist/runtime/middleware/auth.d.ts +10 -0
- package/dist/runtime/middleware/auth.d.ts.map +1 -0
- package/dist/runtime/middleware/auth.js +15 -0
- package/dist/runtime/middleware/permissions.d.ts +20 -0
- package/dist/runtime/middleware/permissions.d.ts.map +1 -0
- package/dist/runtime/middleware/permissions.js +27 -0
- package/dist/runtime/middleware/roles.d.ts +12 -0
- package/dist/runtime/middleware/roles.d.ts.map +1 -0
- package/dist/runtime/middleware/roles.js +13 -0
- package/dist/runtime/page-meta.nuxt.d.ts +23 -0
- package/dist/runtime/plugins/00.fetch.d.ts +21 -0
- package/dist/runtime/plugins/00.fetch.d.ts.map +1 -0
- package/dist/runtime/plugins/00.fetch.js +57 -0
- package/dist/runtime/plugins/01.init.server.d.ts +14 -0
- package/dist/runtime/plugins/01.init.server.d.ts.map +1 -0
- package/dist/runtime/plugins/01.init.server.js +37 -0
- package/dist/runtime/types.d.ts +62 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime/types.js +10 -0
- package/dist/runtime/types.nuxt.d.ts +18 -0
- package/dist/runtime/utils/useMapoFetch.d.ts +30 -0
- package/dist/runtime/utils/useMapoFetch.d.ts.map +1 -0
- package/dist/runtime/utils/useMapoFetch.js +8 -0
- package/dist/types.d.mts +13 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +58 -15
- package/src/index.ts +4 -0
- package/src/module.ts +102 -0
- package/src/runtime/__tests__/plugins/00.fetch.spec.ts +144 -0
- package/src/runtime/api/crud.ts +130 -0
- package/src/runtime/api/multipart.ts +78 -0
- package/src/runtime/auth/useCanAccessRoute.ts +48 -0
- package/src/runtime/auth/useMapoAuth.ts +91 -0
- package/src/runtime/composables/useMapo.ts +61 -0
- package/src/runtime/middleware/auth.ts +24 -0
- package/src/runtime/middleware/permissions.ts +52 -0
- package/src/runtime/middleware/roles.ts +23 -0
- package/src/runtime/page-meta.nuxt.d.ts +23 -0
- package/src/runtime/plugins/00.fetch.ts +85 -0
- package/src/runtime/plugins/01.init.server.ts +62 -0
- package/src/runtime/types.nuxt.d.ts +18 -0
- package/src/runtime/types.ts +88 -0
- package/src/runtime/utils/useMapoFetch.ts +41 -0
- package/src/types.ts +13 -0
- package/CHANGELOG.md +0 -248
- package/api/crud.js +0 -141
- package/api/multipart.js +0 -41
- package/index.js +0 -134
- package/interceptor/auth.js +0 -26
- package/interceptor/index.js +0 -5
- package/middleware/auth.js +0 -10
- package/middleware/permissions.js +0 -36
- package/middleware/roles.js +0 -10
- package/module.js +0 -44
- package/templates/options.js +0 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
|
|
4
|
+
// Mock setupPlugin function behavior for testing
|
|
5
|
+
function setupFetchPlugin() {
|
|
6
|
+
let redirectInFlight = false;
|
|
7
|
+
const pending = ref(0);
|
|
8
|
+
|
|
9
|
+
const handle = (status: number, requestUrl: string, logoutUrl: string) => {
|
|
10
|
+
if (
|
|
11
|
+
status === 401 &&
|
|
12
|
+
!requestUrl.includes(logoutUrl) &&
|
|
13
|
+
!redirectInFlight
|
|
14
|
+
) {
|
|
15
|
+
redirectInFlight = true;
|
|
16
|
+
// In real plugin, would redirect; here we just track the intent
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const mapoFetch = {
|
|
21
|
+
onRequest: () => {
|
|
22
|
+
pending.value++;
|
|
23
|
+
},
|
|
24
|
+
onResponse: (ctx: { response: { status: number }; request: string }) => {
|
|
25
|
+
pending.value--;
|
|
26
|
+
handle(ctx.response.status, ctx.request, "/logout");
|
|
27
|
+
},
|
|
28
|
+
onResponseError: (ctx: {
|
|
29
|
+
response?: { status: number };
|
|
30
|
+
request: string;
|
|
31
|
+
}) => {
|
|
32
|
+
pending.value--;
|
|
33
|
+
handle(ctx.response?.status ?? 0, ctx.request, "/logout");
|
|
34
|
+
},
|
|
35
|
+
onRequestError: () => {
|
|
36
|
+
pending.value--;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return { pending, mapoFetch };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("Mapo Fetch Plugin — Pending Counter", () => {
|
|
44
|
+
let fixture: ReturnType<typeof setupFetchPlugin>;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
fixture = setupFetchPlugin();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should increment pending on request and decrement on successful response", () => {
|
|
51
|
+
const { pending, mapoFetch } = fixture;
|
|
52
|
+
|
|
53
|
+
expect(pending.value).toBe(0);
|
|
54
|
+
|
|
55
|
+
// Simulate request
|
|
56
|
+
mapoFetch.onRequest();
|
|
57
|
+
expect(pending.value).toBe(1);
|
|
58
|
+
|
|
59
|
+
// Simulate successful response (200)
|
|
60
|
+
mapoFetch.onResponse({
|
|
61
|
+
response: { status: 200 },
|
|
62
|
+
request: "/api/users",
|
|
63
|
+
});
|
|
64
|
+
expect(pending.value).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should decrement pending exactly once on HTTP error (not double-decrement)", () => {
|
|
68
|
+
const { pending, mapoFetch } = fixture;
|
|
69
|
+
|
|
70
|
+
expect(pending.value).toBe(0);
|
|
71
|
+
|
|
72
|
+
// Simulate request
|
|
73
|
+
mapoFetch.onRequest();
|
|
74
|
+
expect(pending.value).toBe(1);
|
|
75
|
+
|
|
76
|
+
// Simulate HTTP error (500): onResponseError is called instead of onResponse
|
|
77
|
+
mapoFetch.onResponseError({
|
|
78
|
+
response: { status: 500 },
|
|
79
|
+
request: "/api/users",
|
|
80
|
+
});
|
|
81
|
+
expect(pending.value).toBe(0);
|
|
82
|
+
// Should NOT be -1 (which would indicate double-decrement)
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should decrement pending on network error (onRequestError)", () => {
|
|
86
|
+
const { pending, mapoFetch } = fixture;
|
|
87
|
+
|
|
88
|
+
expect(pending.value).toBe(0);
|
|
89
|
+
|
|
90
|
+
// Simulate request
|
|
91
|
+
mapoFetch.onRequest();
|
|
92
|
+
expect(pending.value).toBe(1);
|
|
93
|
+
|
|
94
|
+
// Simulate network error: onRequestError is called
|
|
95
|
+
mapoFetch.onRequestError();
|
|
96
|
+
expect(pending.value).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should handle multiple concurrent requests correctly", () => {
|
|
100
|
+
const { pending, mapoFetch } = fixture;
|
|
101
|
+
|
|
102
|
+
// Three concurrent requests
|
|
103
|
+
mapoFetch.onRequest(); // Request 1
|
|
104
|
+
mapoFetch.onRequest(); // Request 2
|
|
105
|
+
mapoFetch.onRequest(); // Request 3
|
|
106
|
+
expect(pending.value).toBe(3);
|
|
107
|
+
|
|
108
|
+
// First completes successfully
|
|
109
|
+
mapoFetch.onResponse({
|
|
110
|
+
response: { status: 200 },
|
|
111
|
+
request: "/api/users",
|
|
112
|
+
});
|
|
113
|
+
expect(pending.value).toBe(2);
|
|
114
|
+
|
|
115
|
+
// Second fails with HTTP error
|
|
116
|
+
mapoFetch.onResponseError({
|
|
117
|
+
response: { status: 500 },
|
|
118
|
+
request: "/api/posts",
|
|
119
|
+
});
|
|
120
|
+
expect(pending.value).toBe(1);
|
|
121
|
+
|
|
122
|
+
// Third fails with network error
|
|
123
|
+
mapoFetch.onRequestError();
|
|
124
|
+
expect(pending.value).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should never go negative", () => {
|
|
128
|
+
const { pending, mapoFetch } = fixture;
|
|
129
|
+
|
|
130
|
+
// Ensure pending never goes negative even with edge cases
|
|
131
|
+
mapoFetch.onRequest();
|
|
132
|
+
expect(pending.value).toBe(1);
|
|
133
|
+
|
|
134
|
+
// Simulate response
|
|
135
|
+
mapoFetch.onResponse({
|
|
136
|
+
response: { status: 200 },
|
|
137
|
+
request: "/api/users",
|
|
138
|
+
});
|
|
139
|
+
expect(pending.value).toBe(0);
|
|
140
|
+
|
|
141
|
+
// Should not go negative
|
|
142
|
+
expect(pending.value).toBeGreaterThanOrEqual(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { normalizeEndpoint } from "@mapomodule/utils";
|
|
2
|
+
import { applyMultipartPolicy } from "./multipart";
|
|
3
|
+
import { useMapoFetch } from "../utils/useMapoFetch";
|
|
4
|
+
import { MultipartPolicyEnum } from "../types";
|
|
5
|
+
import type {
|
|
6
|
+
PaginatedResponse,
|
|
7
|
+
CrudConfig,
|
|
8
|
+
CrudOptions,
|
|
9
|
+
CrudRepository,
|
|
10
|
+
} from "../types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a typed CRUD repository bound to a resource endpoint.
|
|
14
|
+
*
|
|
15
|
+
* The returned repository exposes common operations (`list`, `detail`, `create`,
|
|
16
|
+
* `update`, `partialUpdate`, `delete`) plus utility actions (`updateOrCreate`,
|
|
17
|
+
* `updateOrder`). Requests are executed through the shared Mapo fetch client.
|
|
18
|
+
*
|
|
19
|
+
* Configuration precedence:
|
|
20
|
+
* - `higherConf` defines repository-level default request options.
|
|
21
|
+
* - Per-call config arguments override repository-level defaults.
|
|
22
|
+
*
|
|
23
|
+
* Multipart handling:
|
|
24
|
+
* - Write methods pass payloads through `applyMultipartPolicy`, using
|
|
25
|
+
* `MultipartPolicyEnum.Auto` unless overridden in `CrudOptions`.
|
|
26
|
+
*
|
|
27
|
+
* @typeParam T Entity type handled by the repository.
|
|
28
|
+
* @param endpoint Resource endpoint (normalized to a trailing-slash base path).
|
|
29
|
+
* @param higherConf Optional repository-level fetch configuration defaults.
|
|
30
|
+
* @returns A typed `CrudRepository<T>` for the specified endpoint.
|
|
31
|
+
*/
|
|
32
|
+
export function useCrud<T>(
|
|
33
|
+
endpoint: string,
|
|
34
|
+
higherConf?: CrudConfig,
|
|
35
|
+
): CrudRepository<T> {
|
|
36
|
+
const base = normalizeEndpoint(endpoint);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Merges higher-level CRUD config with per-call config.
|
|
40
|
+
*
|
|
41
|
+
* Per-call options take precedence over defaults provided at repository
|
|
42
|
+
* creation time.
|
|
43
|
+
*/
|
|
44
|
+
function merged(local?: CrudConfig): Record<string, unknown> {
|
|
45
|
+
return { ...higherConf, ...local };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Executes a typed request through the shared Mapo fetch client.
|
|
50
|
+
*
|
|
51
|
+
* @param path Target URL path.
|
|
52
|
+
* @param options Request options passed to `$mapoFetch`.
|
|
53
|
+
* @returns A promise resolving to the typed response payload.
|
|
54
|
+
*/
|
|
55
|
+
function fetch<R>(
|
|
56
|
+
path: string,
|
|
57
|
+
options: Record<string, unknown>,
|
|
58
|
+
): Promise<R> {
|
|
59
|
+
return useMapoFetch().fetch(path, options as never) as Promise<R>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
list(params, config) {
|
|
64
|
+
return fetch<PaginatedResponse<T>>(base, {
|
|
65
|
+
method: "GET",
|
|
66
|
+
params,
|
|
67
|
+
...merged(config),
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
detail(id, config) {
|
|
72
|
+
return fetch<T>(`${base}${id}/`, { method: "GET", ...merged(config) });
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
create(data, config, opts: CrudOptions = {}) {
|
|
76
|
+
const body = applyMultipartPolicy(
|
|
77
|
+
data as Record<string, unknown>,
|
|
78
|
+
opts.multipart ?? MultipartPolicyEnum.Auto,
|
|
79
|
+
);
|
|
80
|
+
return fetch<T>(base, { method: "POST", body, ...merged(config) });
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
update(id, data, config, opts: CrudOptions = {}) {
|
|
84
|
+
const body = applyMultipartPolicy(
|
|
85
|
+
data as Record<string, unknown>,
|
|
86
|
+
opts.multipart ?? MultipartPolicyEnum.Auto,
|
|
87
|
+
);
|
|
88
|
+
return fetch<T>(`${base}${id}/`, {
|
|
89
|
+
method: "PUT",
|
|
90
|
+
body,
|
|
91
|
+
...merged(config),
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
partialUpdate(id, diff, config, opts: CrudOptions = {}) {
|
|
96
|
+
const body = applyMultipartPolicy(
|
|
97
|
+
diff as Record<string, unknown>,
|
|
98
|
+
opts.multipart ?? MultipartPolicyEnum.Auto,
|
|
99
|
+
);
|
|
100
|
+
return fetch<T>(`${base}${id}/`, {
|
|
101
|
+
method: "PATCH",
|
|
102
|
+
body,
|
|
103
|
+
...merged(config),
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
delete(id, config) {
|
|
108
|
+
return fetch<void>(`${base}${id}/`, {
|
|
109
|
+
method: "DELETE",
|
|
110
|
+
...merged(config),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
updateOrCreate(data, config, opts) {
|
|
115
|
+
if (data.id) {
|
|
116
|
+
const { id, ...rest } = data;
|
|
117
|
+
return this.update(id, rest as Partial<T>, config, opts);
|
|
118
|
+
}
|
|
119
|
+
return this.create(data as Partial<T>, config, opts);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
updateOrder(startId, endId, config) {
|
|
123
|
+
return fetch<void>(`${base}update_order/`, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
body: { startId, endId },
|
|
126
|
+
...merged(config),
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { MultipartPolicy } from "../types";
|
|
2
|
+
import { MultipartPolicyEnum } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flattened file entry extracted from a nested payload.
|
|
6
|
+
*/
|
|
7
|
+
interface FileEntry {
|
|
8
|
+
/** Dot-notation path where the file/blob was found in the original payload. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** File-like value to append to FormData. */
|
|
11
|
+
file: File | Blob;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Recursively collects all `File`/`Blob` values from a nested payload.
|
|
16
|
+
*
|
|
17
|
+
* @param obj Value to inspect.
|
|
18
|
+
* @param path Current dot-notation traversal path.
|
|
19
|
+
* @returns A flat list of file entries with their original paths.
|
|
20
|
+
*/
|
|
21
|
+
function collectFiles(obj: unknown, path = ""): FileEntry[] {
|
|
22
|
+
if (obj instanceof File || obj instanceof Blob) {
|
|
23
|
+
return [{ path, file: obj }];
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(obj)) {
|
|
26
|
+
return obj.flatMap((item, i) =>
|
|
27
|
+
collectFiles(item, path ? `${path}.${i}` : String(i)),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (obj !== null && typeof obj === "object") {
|
|
31
|
+
return Object.entries(obj as Record<string, unknown>).flatMap(
|
|
32
|
+
([key, val]) => collectFiles(val, path ? `${path}.${key}` : key),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Converts a payload into multipart `FormData`.
|
|
40
|
+
*
|
|
41
|
+
* The full payload is serialized under the `data` field, while every extracted
|
|
42
|
+
* `File`/`Blob` is appended as a dedicated FormData field at its dot-path key.
|
|
43
|
+
*
|
|
44
|
+
* @param obj Source payload object.
|
|
45
|
+
* @returns Multipart form data representation of `obj`.
|
|
46
|
+
*/
|
|
47
|
+
function toFormData(obj: Record<string, unknown>): FormData {
|
|
48
|
+
const files = collectFiles(obj);
|
|
49
|
+
const fd = new FormData();
|
|
50
|
+
// JSON.stringify naturally drops File/Blob — used as the plain-data field
|
|
51
|
+
fd.append("data", JSON.stringify(obj));
|
|
52
|
+
for (const { path, file } of files) {
|
|
53
|
+
fd.append(path, file);
|
|
54
|
+
}
|
|
55
|
+
return fd;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Applies the configured multipart policy to a request payload.
|
|
60
|
+
*
|
|
61
|
+
* Policy behavior:
|
|
62
|
+
* - `Disable`: always return the original object payload.
|
|
63
|
+
* - `Force`: always return a `FormData` payload.
|
|
64
|
+
* - `Auto`: return `FormData` only when at least one `File`/`Blob` is present.
|
|
65
|
+
*
|
|
66
|
+
* @param payload Request payload object.
|
|
67
|
+
* @param policy Multipart transformation policy.
|
|
68
|
+
* @returns Either the original payload or a `FormData` instance.
|
|
69
|
+
*/
|
|
70
|
+
export function applyMultipartPolicy(
|
|
71
|
+
payload: Record<string, unknown>,
|
|
72
|
+
policy: MultipartPolicy,
|
|
73
|
+
): FormData | Record<string, unknown> {
|
|
74
|
+
if (policy === MultipartPolicyEnum.Disable) return payload;
|
|
75
|
+
if (policy === MultipartPolicyEnum.Force) return toFormData(payload);
|
|
76
|
+
// auto: transform only if File/Blob are present
|
|
77
|
+
return collectFiles(payload).length > 0 ? toFormData(payload) : payload;
|
|
78
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useAuthStore } from "@mapomodule/store/runtime/stores/auth";
|
|
2
|
+
import type { RouteMeta } from "vue-router";
|
|
3
|
+
|
|
4
|
+
/** Two-format permission spec: Django model shorthand or explicit codename list. */
|
|
5
|
+
export type RoutePermissions = { model: string } | string[];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns whether the current user can access a route based on its meta.
|
|
9
|
+
* Mirrors the logic of the `auth`, `permissions`, and `roles` middlewares
|
|
10
|
+
* so the sidebar and any UI that gates visibility stays in sync with navigation guards.
|
|
11
|
+
*/
|
|
12
|
+
export function useCanAccessRoute(meta: RouteMeta): boolean {
|
|
13
|
+
const auth = useAuthStore();
|
|
14
|
+
|
|
15
|
+
const raw = meta.middleware;
|
|
16
|
+
const middleware = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
17
|
+
|
|
18
|
+
// auth middleware: must be logged in
|
|
19
|
+
if (middleware.includes("auth") && !auth.isAuthenticated) return false;
|
|
20
|
+
|
|
21
|
+
// roles middleware
|
|
22
|
+
if (middleware.includes("roles")) {
|
|
23
|
+
const required = meta.roles as string[] | undefined;
|
|
24
|
+
if (required?.length && !auth.info?.is_superuser) {
|
|
25
|
+
const userGroups = auth.info?.groups ?? [];
|
|
26
|
+
if (!required.some((r: string) => userGroups.includes(r))) return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// permissions middleware
|
|
31
|
+
if (middleware.includes("permissions")) {
|
|
32
|
+
const perms = meta.permissions as RoutePermissions | undefined;
|
|
33
|
+
if (perms && !auth.info?.is_superuser) {
|
|
34
|
+
if (Array.isArray(perms)) {
|
|
35
|
+
if (!perms.every((p) => auth.rawPermissions.includes(p))) return false;
|
|
36
|
+
} else {
|
|
37
|
+
const hasView = auth.rawPermissions.some(
|
|
38
|
+
(p) =>
|
|
39
|
+
p === `view_${perms.model}` ||
|
|
40
|
+
(p.endsWith(`_${perms.model}`) && p.startsWith("view")),
|
|
41
|
+
);
|
|
42
|
+
if (!hasView) return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useNuxtApp, navigateTo, useCookie, useRuntimeConfig } from "nuxt/app";
|
|
2
|
+
import { useAuthStore } from "@mapomodule/store/runtime/stores/auth";
|
|
3
|
+
import { CoreCookieEnum } from "../types";
|
|
4
|
+
import type { Credentials, MapoCoreRuntimeConfig } from "../types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Optional endpoint overrides for auth operations.
|
|
8
|
+
*/
|
|
9
|
+
type AuthOverrides = {
|
|
10
|
+
/** Login endpoint used to create the server session. */
|
|
11
|
+
authLoginUrl?: string;
|
|
12
|
+
/** Endpoint used to fetch the current authenticated user profile. */
|
|
13
|
+
userInfoApi?: string;
|
|
14
|
+
/** Logout endpoint used to invalidate the server session. */
|
|
15
|
+
logoutUrl?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Provides authentication helpers built on top of the shared Mapo fetch client.
|
|
20
|
+
*
|
|
21
|
+
* Endpoints are resolved from runtime config and can be selectively overridden
|
|
22
|
+
* through `options`.
|
|
23
|
+
*
|
|
24
|
+
* @param options Optional endpoint overrides for login, logout, and user fetch.
|
|
25
|
+
* @returns Auth helpers: `login`, `logout`, and `fetchUser`.
|
|
26
|
+
*/
|
|
27
|
+
export function useMapoAuth(options?: AuthOverrides) {
|
|
28
|
+
const rc = useRuntimeConfig().public.mapoCore as MapoCoreRuntimeConfig;
|
|
29
|
+
const authLoginUrl = options?.authLoginUrl ?? rc.authLoginUrl;
|
|
30
|
+
const userInfoApi = options?.userInfoApi ?? rc.userInfoApi;
|
|
31
|
+
const logoutUrl = options?.logoutUrl ?? rc.logoutUrl;
|
|
32
|
+
const loginUrl = rc.loginUrl;
|
|
33
|
+
|
|
34
|
+
const { $mapoFetch } = useNuxtApp() as ReturnType<typeof useNuxtApp> & {
|
|
35
|
+
$mapoFetch: typeof $fetch;
|
|
36
|
+
};
|
|
37
|
+
const authStore = useAuthStore();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fetches the current user from `userInfoApi` and hydrates the auth store.
|
|
41
|
+
*/
|
|
42
|
+
async function fetchUser(): Promise<void> {
|
|
43
|
+
const user = await $mapoFetch<
|
|
44
|
+
import("@mapomodule/store/runtime/types").MapoUser
|
|
45
|
+
>(userInfoApi, { method: "GET" });
|
|
46
|
+
authStore.setUser(user);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Performs login and then confirms the session by fetching the current user.
|
|
51
|
+
*
|
|
52
|
+
* The backend is expected to set an HttpOnly session cookie on successful
|
|
53
|
+
* login. `fetchUser` is used immediately after to hydrate store state.
|
|
54
|
+
*
|
|
55
|
+
* @param credentials User credentials payload for the login endpoint.
|
|
56
|
+
*/
|
|
57
|
+
async function login(credentials: Credentials): Promise<void> {
|
|
58
|
+
// The backend sets the session cookie (HttpOnly) on a successful login.
|
|
59
|
+
// We confirm the session by fetching the user — if it succeeds, the
|
|
60
|
+
// store is hydrated and isAuthenticated becomes true.
|
|
61
|
+
await $mapoFetch(authLoginUrl, { method: "POST", body: credentials });
|
|
62
|
+
await fetchUser();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Logs out the current user and always performs local cleanup.
|
|
67
|
+
*
|
|
68
|
+
* Cleanup includes clearing the session cookie mirror, resetting auth store
|
|
69
|
+
* state, removing persisted Mapo draft keys from localStorage, and navigating
|
|
70
|
+
* to the configured login page.
|
|
71
|
+
*/
|
|
72
|
+
async function logout(): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
await $mapoFetch(logoutUrl, { method: "GET" });
|
|
75
|
+
} finally {
|
|
76
|
+
useCookie(CoreCookieEnum.Session).value = null;
|
|
77
|
+
authStore.reset();
|
|
78
|
+
if (typeof localStorage !== "undefined") {
|
|
79
|
+
const toRemove: string[] = [];
|
|
80
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
81
|
+
const k = localStorage.key(i);
|
|
82
|
+
if (k?.startsWith("mapo:draft:")) toRemove.push(k);
|
|
83
|
+
}
|
|
84
|
+
toRemove.forEach((k) => localStorage.removeItem(k));
|
|
85
|
+
}
|
|
86
|
+
await navigateTo(loginUrl);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { login, logout, fetchUser };
|
|
91
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useSnackStore } from "@mapomodule/store/runtime/stores/snack";
|
|
2
|
+
import { useConfirmStore } from "@mapomodule/store/runtime/stores/confirm";
|
|
3
|
+
import { useMapoAuth } from "../auth/useMapoAuth";
|
|
4
|
+
import { useCrud } from "../api/crud";
|
|
5
|
+
import type { MapoOptions } from "../../types";
|
|
6
|
+
import type { CrudRepository } from "../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* API layer exposed by {@link useMapo}.
|
|
10
|
+
*/
|
|
11
|
+
export interface MapoApiLayer {
|
|
12
|
+
/**
|
|
13
|
+
* Creates a CRUD repository bound to the provided endpoint.
|
|
14
|
+
*
|
|
15
|
+
* @param endpoint API endpoint for the resource collection.
|
|
16
|
+
* @returns A typed repository with list/detail/create/update/delete helpers.
|
|
17
|
+
*/
|
|
18
|
+
crud<T>(endpoint: string): CrudRepository<T>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* High-level facade that groups the most common Mapo runtime capabilities.
|
|
23
|
+
*
|
|
24
|
+
* This keeps feature access ergonomic in components by exposing API, auth,
|
|
25
|
+
* feedback stores, and resolved options from a single composable call.
|
|
26
|
+
*/
|
|
27
|
+
export interface MapoFacade {
|
|
28
|
+
/** Resource-oriented API helpers. */
|
|
29
|
+
$api: MapoApiLayer;
|
|
30
|
+
/** Authentication helpers (`login`, `logout`, `fetchUser`). */
|
|
31
|
+
$auth: ReturnType<typeof useMapoAuth>;
|
|
32
|
+
/** Global snackbar store for user feedback messages. */
|
|
33
|
+
$snack: ReturnType<typeof useSnackStore>;
|
|
34
|
+
/** Global confirm dialog store for destructive/critical actions. */
|
|
35
|
+
$confirm: ReturnType<typeof useConfirmStore>;
|
|
36
|
+
/** Effective options passed to the facade factory. */
|
|
37
|
+
$options: MapoOptions;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates the Mapo facade used across admin pages and composables.
|
|
42
|
+
*
|
|
43
|
+
* The facade is a convenience aggregator over lower-level composables/stores:
|
|
44
|
+
* - `$api.crud<T>(endpoint)` delegates to `useCrud<T>(endpoint)`.
|
|
45
|
+
* - `$auth` delegates to `useMapoAuth(options)` (same overrides).
|
|
46
|
+
* - `$snack` and `$confirm` expose the shared UI feedback stores.
|
|
47
|
+
*
|
|
48
|
+
* @param options Optional runtime overrides forwarded to the auth layer.
|
|
49
|
+
* @returns A `MapoFacade` with grouped API/auth/feedback capabilities.
|
|
50
|
+
*/
|
|
51
|
+
export function useMapo(options: MapoOptions = {}): MapoFacade {
|
|
52
|
+
return {
|
|
53
|
+
$api: {
|
|
54
|
+
crud: <T>(endpoint: string): CrudRepository<T> => useCrud<T>(endpoint),
|
|
55
|
+
},
|
|
56
|
+
$auth: useMapoAuth(options),
|
|
57
|
+
$snack: useSnackStore(),
|
|
58
|
+
$confirm: useConfirmStore(),
|
|
59
|
+
$options: options,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineNuxtRouteMiddleware,
|
|
3
|
+
navigateTo,
|
|
4
|
+
useRuntimeConfig,
|
|
5
|
+
} from "nuxt/app";
|
|
6
|
+
import { useAuthStore } from "@mapomodule/store/runtime/stores/auth";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Route middleware that protects authenticated pages.
|
|
10
|
+
*
|
|
11
|
+
* If the user is not authenticated, it redirects to the configured login page
|
|
12
|
+
* and appends the current route (`to.fullPath`) as a `redirect` query param,
|
|
13
|
+
* so the app can return the user to the original destination after login.
|
|
14
|
+
*/
|
|
15
|
+
export default defineNuxtRouteMiddleware((to) => {
|
|
16
|
+
const loginUrl = (useRuntimeConfig().public.mapoCore as { loginUrl: string })
|
|
17
|
+
.loginUrl;
|
|
18
|
+
const auth = useAuthStore();
|
|
19
|
+
if (!auth.isAuthenticated) {
|
|
20
|
+
return navigateTo(
|
|
21
|
+
`${loginUrl}?redirect=${encodeURIComponent(to.fullPath)}`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defineNuxtRouteMiddleware, createError } from "nuxt/app";
|
|
2
|
+
import { useAuthStore } from "@mapomodule/store/runtime/stores/auth";
|
|
3
|
+
|
|
4
|
+
type RoutePermissions = { model: string } | string[];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Route middleware that enforces page-level permissions from `to.meta.permissions`.
|
|
8
|
+
*
|
|
9
|
+
* Supported formats:
|
|
10
|
+
* - `string[]`: all listed raw permission codenames are required.
|
|
11
|
+
* - `{ model: string }`: permissions are derived from codenames ending with
|
|
12
|
+
* `_<model>` (for example, `view_article`, `change_article`).
|
|
13
|
+
*
|
|
14
|
+
* Behavior:
|
|
15
|
+
* - If no permissions are declared, access is allowed.
|
|
16
|
+
* - Superusers always pass; for model-based permissions, full CRUD actions are
|
|
17
|
+
* exposed in `auth.pagePermissions[routeName]`.
|
|
18
|
+
* - For regular users, access is denied with HTTP 403 when required permissions
|
|
19
|
+
* are missing (or `view` is missing for model-based permissions).
|
|
20
|
+
* - For allowed model-based routes, resolved actions are stored in
|
|
21
|
+
* `auth.pagePermissions[routeName]`.
|
|
22
|
+
*/
|
|
23
|
+
export default defineNuxtRouteMiddleware((to) => {
|
|
24
|
+
const auth = useAuthStore();
|
|
25
|
+
const perms = to.meta.permissions as RoutePermissions | undefined;
|
|
26
|
+
if (!perms) return;
|
|
27
|
+
if (auth.info?.is_superuser) {
|
|
28
|
+
if (!Array.isArray(perms)) {
|
|
29
|
+
auth.pagePermissions[String(to.name)] = [
|
|
30
|
+
"view",
|
|
31
|
+
"add",
|
|
32
|
+
"change",
|
|
33
|
+
"delete",
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (Array.isArray(perms)) {
|
|
40
|
+
if (!perms.every((p) => auth.rawPermissions.includes(p)))
|
|
41
|
+
throw createError({ statusCode: 403, message: "Forbidden" });
|
|
42
|
+
} else {
|
|
43
|
+
const actions = auth.rawPermissions
|
|
44
|
+
.filter((p) => p.endsWith(`_${perms.model}`))
|
|
45
|
+
.map((p) => p.replace(`_${perms.model}`, ""));
|
|
46
|
+
|
|
47
|
+
if (!actions.includes("view"))
|
|
48
|
+
throw createError({ statusCode: 403, message: "Forbidden" });
|
|
49
|
+
|
|
50
|
+
auth.pagePermissions[String(to.name)] = actions;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineNuxtRouteMiddleware, createError } from "nuxt/app";
|
|
2
|
+
import { useAuthStore } from "@mapomodule/store/runtime/stores/auth";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Route middleware that enforces role-based access from `to.meta.roles`.
|
|
6
|
+
*
|
|
7
|
+
* Access rules:
|
|
8
|
+
* - If no roles are configured on the route, access is allowed.
|
|
9
|
+
* - Superusers always bypass role checks.
|
|
10
|
+
* - For other users, at least one required role must match a user group.
|
|
11
|
+
* - If no match is found, navigation is blocked with HTTP 403.
|
|
12
|
+
*/
|
|
13
|
+
export default defineNuxtRouteMiddleware((to) => {
|
|
14
|
+
const auth = useAuthStore();
|
|
15
|
+
const required = to.meta.roles as string[] | undefined;
|
|
16
|
+
if (!required?.length) return;
|
|
17
|
+
if (auth.info?.is_superuser) return;
|
|
18
|
+
const userGroups = auth.info?.groups ?? [];
|
|
19
|
+
const hasRole = required.some((r) => userGroups.includes(r));
|
|
20
|
+
if (!hasRole) {
|
|
21
|
+
throw createError({ statusCode: 403, message: "Forbidden" });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
declare module "#app" {
|
|
2
|
+
interface PageMeta {
|
|
3
|
+
/**
|
|
4
|
+
* Permissions required to access this route. Checked by the 'permissions' middleware and sidebar visibility.
|
|
5
|
+
* - `{ model: string }` — derives `view_<model>` from a Django model name and populates `authStore.pagePermissions`
|
|
6
|
+
* - `string[]` — raw Django permission codenames (e.g. `['view_article', 'change_article']`)
|
|
7
|
+
*/
|
|
8
|
+
permissions?: { model: string } | string[];
|
|
9
|
+
/** Required role/group names. Checked by the 'roles' middleware. */
|
|
10
|
+
roles?: string[];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare module "vue-router" {
|
|
15
|
+
interface RouteMeta {
|
|
16
|
+
permissions?: { model: string } | string[];
|
|
17
|
+
roles?: string[];
|
|
18
|
+
/** Route middleware names applied to this route. Read by useCanAccessRoute for sidebar visibility. */
|
|
19
|
+
middleware?: string | string[];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export {};
|