@jskit-ai/users-web 0.1.162 → 0.1.164

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.
@@ -3,7 +3,7 @@ import { HOME_COG_OUTLET } from "./src/shared/toolsOutletContracts.js";
3
3
  export default Object.freeze({
4
4
  packageVersion: 1,
5
5
  packageId: "@jskit-ai/users-web",
6
- version: "0.1.162",
6
+ version: "0.1.164",
7
7
  kind: "runtime",
8
8
  description: "Users web module: account/profile UI plus shared users web widgets.",
9
9
  dependsOn: [
@@ -286,12 +286,12 @@ export default Object.freeze({
286
286
  dependencies: {
287
287
  runtime: {
288
288
  "@mdi/js": "^7.4.47",
289
- "@jskit-ai/http-runtime": "0.1.143",
290
- "@jskit-ai/realtime": "0.1.142",
291
- "@jskit-ai/kernel": "0.1.145",
292
- "@jskit-ai/shell-web": "0.1.146",
293
- "@jskit-ai/uploads-image-web": "0.1.121",
294
- "@jskit-ai/users-core": "0.1.158"
289
+ "@jskit-ai/http-runtime": "0.1.145",
290
+ "@jskit-ai/realtime": "0.1.143",
291
+ "@jskit-ai/kernel": "0.1.146",
292
+ "@jskit-ai/shell-web": "0.1.147",
293
+ "@jskit-ai/uploads-image-web": "0.1.122",
294
+ "@jskit-ai/users-core": "0.1.160"
295
295
  },
296
296
  dev: {}
297
297
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.162",
3
+ "version": "0.1.164",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -31,6 +31,7 @@
31
31
  "./client/composables/useList": "./src/client/composables/records/useList.js",
32
32
  "./client/composables/useCrudList": "./src/client/composables/records/useCrudList.js",
33
33
  "./client/composables/useCrudListScreen": "./src/client/composables/useCrudListScreen.js",
34
+ "./client/crudHttpClient": "./src/client/composables/crud/crudHttpClientSupport.js",
34
35
  "./client/composables/useCrudListParentTitle": "./src/client/composables/useCrudListParentTitle.js",
35
36
  "./client/composables/useRealtimeQueryInvalidation": "./src/client/composables/useRealtimeQueryInvalidation.js",
36
37
  "./client/composables/useSurfaceRouteContext": "./src/client/composables/useSurfaceRouteContext.js",
@@ -46,12 +47,12 @@
46
47
  },
47
48
  "dependencies": {
48
49
  "@mdi/js": "^7.4.47",
49
- "@jskit-ai/http-runtime": "0.1.143",
50
- "@jskit-ai/kernel": "0.1.145",
51
- "@jskit-ai/realtime": "0.1.142",
52
- "@jskit-ai/shell-web": "0.1.146",
53
- "@jskit-ai/uploads-image-web": "0.1.121",
54
- "@jskit-ai/users-core": "0.1.158"
50
+ "@jskit-ai/http-runtime": "0.1.145",
51
+ "@jskit-ai/kernel": "0.1.146",
52
+ "@jskit-ai/realtime": "0.1.143",
53
+ "@jskit-ai/shell-web": "0.1.147",
54
+ "@jskit-ai/uploads-image-web": "0.1.122",
55
+ "@jskit-ai/users-core": "0.1.160"
55
56
  },
56
57
  "peerDependencies": {
57
58
  "@tanstack/vue-query": "^5.90.5",
@@ -0,0 +1,57 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { getUsersWebHttpClient } from "../../lib/httpClient.js";
3
+
4
+ const CRUD_API_ACCESS_AUTHENTICATED = "authenticated";
5
+ const CRUD_API_ACCESS_PUBLIC = "public";
6
+ const CRUD_API_ACCESS_VALUES = Object.freeze([
7
+ CRUD_API_ACCESS_AUTHENTICATED,
8
+ CRUD_API_ACCESS_PUBLIC
9
+ ]);
10
+ const publicCrudClients = new WeakSet();
11
+
12
+ function normalizeCrudApiAccess(value = "") {
13
+ const normalized = normalizeText(value).toLowerCase() || CRUD_API_ACCESS_AUTHENTICATED;
14
+ if (CRUD_API_ACCESS_VALUES.includes(normalized)) {
15
+ return normalized;
16
+ }
17
+
18
+ throw new TypeError(
19
+ `CRUD resource apiAccess must be one of: ${CRUD_API_ACCESS_VALUES.join(", ")}. ` +
20
+ `Received: ${String(value || "") || "(empty)"}`
21
+ );
22
+ }
23
+
24
+ function resolveCrudHttpClient(resource = null, { client = null } = {}) {
25
+ const activeClient = client || getUsersWebHttpClient();
26
+ if (!activeClient || typeof activeClient.request !== "function") {
27
+ throw new TypeError("resolveCrudHttpClient requires a client with request().");
28
+ }
29
+
30
+ if (normalizeCrudApiAccess(resource?.apiAccess) !== CRUD_API_ACCESS_PUBLIC) {
31
+ return activeClient;
32
+ }
33
+ if (publicCrudClients.has(activeClient)) {
34
+ return activeClient;
35
+ }
36
+
37
+ const publicClient = Object.freeze({
38
+ request(url, options = {}) {
39
+ const requestOptions = options && typeof options === "object" && !Array.isArray(options)
40
+ ? options
41
+ : {};
42
+ return activeClient.request(url, {
43
+ ...requestOptions,
44
+ csrf: false
45
+ });
46
+ }
47
+ });
48
+ publicCrudClients.add(publicClient);
49
+ return publicClient;
50
+ }
51
+
52
+ export {
53
+ CRUD_API_ACCESS_AUTHENTICATED,
54
+ CRUD_API_ACCESS_PUBLIC,
55
+ normalizeCrudApiAccess,
56
+ resolveCrudHttpClient
57
+ };
@@ -115,6 +115,37 @@ function toDateTimeLocalInputValue(value) {
115
115
  ].join("-") + `T${padDateTimePart(date.getHours())}:${padDateTimePart(date.getMinutes())}`;
116
116
  }
117
117
 
118
+ function toDateInputValue(value) {
119
+ if (value == null || value === "") {
120
+ return "";
121
+ }
122
+
123
+ if (value instanceof Date) {
124
+ return Number.isNaN(value.getTime())
125
+ ? String(value)
126
+ : value.toISOString().slice(0, 10);
127
+ }
128
+
129
+ const normalized = String(value).trim();
130
+ if (!normalized) {
131
+ return "";
132
+ }
133
+
134
+ const calendarDateMatch = normalized.match(/^(\d{4}-\d{2}-\d{2})(?:$|[T\s])/u);
135
+ if (calendarDateMatch) {
136
+ const calendarDate = calendarDateMatch[1];
137
+ const parsedCalendarDate = new Date(`${calendarDate}T00:00:00.000Z`);
138
+ if (
139
+ !Number.isNaN(parsedCalendarDate.getTime()) &&
140
+ parsedCalendarDate.toISOString().slice(0, 10) === calendarDate
141
+ ) {
142
+ return calendarDate;
143
+ }
144
+ }
145
+
146
+ return normalized;
147
+ }
148
+
118
149
  function toIsoUtcDateTimeValue(value) {
119
150
  const normalized = String(value ?? "").trim();
120
151
  if (!normalized) {
@@ -299,6 +330,11 @@ function applyCrudPayloadToForm(fields = [], model = {}, payload = {}) {
299
330
  continue;
300
331
  }
301
332
 
333
+ if (fieldFormat === "date") {
334
+ targetModel[fieldKey] = toDateInputValue(rawValue);
335
+ continue;
336
+ }
337
+
302
338
  if (fieldFormat === "date-time") {
303
339
  targetModel[fieldKey] = toDateTimeLocalInputValue(rawValue);
304
340
  continue;
@@ -2,6 +2,7 @@ import { computed, proxyRefs, reactive, watch } from "vue";
2
2
  import { useRoute, useRouter } from "vue-router";
3
3
  import { asPlainObject } from "../support/scopeHelpers.js";
4
4
  import { resolveCrudJsonApiTransport } from "../crud/crudJsonApiTransportSupport.js";
5
+ import { resolveCrudHttpClient } from "../crud/crudHttpClientSupport.js";
5
6
  import { useAddEdit } from "./useAddEdit.js";
6
7
  import {
7
8
  resolveCrudBoundValues,
@@ -186,6 +187,9 @@ function useCrudAddEdit({
186
187
  const addEdit = useAddEdit({
187
188
  ...normalizedAddEditOptions,
188
189
  resource: resolvedResource,
190
+ client: resolveCrudHttpClient(resolvedResource, {
191
+ client: normalizedAddEditOptions.client
192
+ }),
189
193
  transport: resolvedTransport,
190
194
  model: form,
191
195
  fieldErrorKeys,
@@ -1,6 +1,7 @@
1
1
  import { computed, unref } from "vue";
2
2
  import { useRoute } from "vue-router";
3
3
  import { resolveCrudJsonApiTransport } from "../crud/crudJsonApiTransportSupport.js";
4
+ import { resolveCrudHttpClient } from "../crud/crudHttpClientSupport.js";
4
5
  import {
5
6
  resolveLookupFieldDisplayValue,
6
7
  resolveRecordTitle
@@ -71,6 +72,9 @@ function useCrudList({
71
72
  });
72
73
  const records = useList({
73
74
  ...listOptions,
75
+ client: resolveCrudHttpClient(resource, {
76
+ client: listOptions.client
77
+ }),
74
78
  transport: resolveCrudJsonApiTransport(listOptions.transport, resource, {
75
79
  mode: "list"
76
80
  }),
@@ -3,6 +3,7 @@ import { useRoute } from "vue-router";
3
3
  import {
4
4
  resolveCrudJsonApiTransport
5
5
  } from "../crud/crudJsonApiTransportSupport.js";
6
+ import { resolveCrudHttpClient } from "../crud/crudHttpClientSupport.js";
6
7
  import {
7
8
  resolveLookupFieldDisplayValue,
8
9
  resolveRecordTitle
@@ -30,6 +31,9 @@ function useCrudView({
30
31
  const view = useView({
31
32
  ...viewOptions,
32
33
  resource,
34
+ client: resolveCrudHttpClient(resource, {
35
+ client: viewOptions.client
36
+ }),
33
37
  transport: resolveCrudJsonApiTransport(viewOptions.transport, resource, {
34
38
  mode: "view"
35
39
  }),
@@ -9,6 +9,7 @@ import {
9
9
  setupRouteChangeCleanup,
10
10
  setupOperationErrorReporting
11
11
  } from "./runtime/operationUiHelpers.js";
12
+ import { resolveCrudHttpClient } from "./crud/crudHttpClientSupport.js";
12
13
 
13
14
  function useCommand({
14
15
  ownershipFilter = ROUTE_VISIBILITY_WORKSPACE,
@@ -18,6 +19,7 @@ function useCommand({
18
19
  runPermissions = [],
19
20
  writeMethod = "POST",
20
21
  client = null,
22
+ resource: commandResource = null,
21
23
  transport = null,
22
24
  placementSource = "users-web.command",
23
25
  fallbackRunError = "Unable to complete action.",
@@ -50,10 +52,10 @@ function useCommand({
50
52
  const routeContext = operationScope.routeContext;
51
53
  const canRun = operationScope.permissionGate("run");
52
54
 
53
- const resource = useEndpointResource({
55
+ const endpointResource = useEndpointResource({
54
56
  path: operationScope.apiPath,
55
57
  enabled: false,
56
- client,
58
+ client: resolveCrudHttpClient(commandResource, { client }),
57
59
  writeMethod,
58
60
  transport,
59
61
  fallbackSaveError: fallbackRunError
@@ -66,7 +68,7 @@ function useCommand({
66
68
 
67
69
  const command = useCommandCore({
68
70
  model,
69
- resource,
71
+ resource: endpointResource,
70
72
  writeMethod,
71
73
  canRun,
72
74
  fieldBag,
@@ -109,7 +111,7 @@ function useCommand({
109
111
  message: command.message,
110
112
  messageType: command.messageType,
111
113
  run: command.run,
112
- resource
114
+ resource: endpointResource
113
115
  });
114
116
  }
115
117
 
@@ -3,6 +3,15 @@ import { useCrudList } from "./records/useCrudList.js";
3
3
  import { useCrudListBulkActions } from "./useCrudListBulkActions.js";
4
4
  import { useCrudListFilters } from "./useCrudListFilters.js";
5
5
  import { useCrudListRowActions } from "./useCrudListRowActions.js";
6
+ import { resolveCrudHttpClient } from "./crud/crudHttpClientSupport.js";
7
+
8
+ function buildCrudListActionContext(records, client) {
9
+ return Object.freeze({
10
+ records,
11
+ reload: records.reload,
12
+ client
13
+ });
14
+ }
6
15
 
7
16
  function formatCrudListCardValue(value) {
8
17
  if (value === null || value === undefined || value === "") {
@@ -102,6 +111,7 @@ function createCrudListDisplayRow(record = {}, index = 0, records = {}) {
102
111
 
103
112
  function useCrudListScreen({
104
113
  adapter = null,
114
+ client = null,
105
115
  resource = null,
106
116
  resourceNamespace = "resource",
107
117
  apiSuffix = "",
@@ -123,6 +133,7 @@ function useCrudListScreen({
123
133
  requestRecoveryLabel = "Records",
124
134
  fallbackLoadError = "Unable to load records."
125
135
  } = {}) {
136
+ const crudHttpClient = resolveCrudHttpClient(resource, { client });
126
137
  const filterRuntime = useCrudListFilters(listFilters);
127
138
  const normalizedRecordChangedEvents = Array.isArray(recordChangedEvents)
128
139
  ? recordChangedEvents
@@ -130,6 +141,7 @@ function useCrudListScreen({
130
141
  const normalizedResourceNamespace = String(resourceNamespace || "resource").trim() || "resource";
131
142
  const records = useCrudList({
132
143
  adapter: adapter || undefined,
144
+ client: crudHttpClient,
133
145
  resource,
134
146
  apiSuffix,
135
147
  queryKeyFactory: (surfaceId = "", workspaceSlug = "") => [
@@ -184,17 +196,11 @@ function useCrudListScreen({
184
196
  );
185
197
  const bulkActions = useCrudListBulkActions(listBulkActions, {
186
198
  resolveRecordId: (record, index) => records.resolveRowKey(record, index),
187
- resolveContext: () => ({
188
- records,
189
- reload: records.reload
190
- })
199
+ resolveContext: () => buildCrudListActionContext(records, crudHttpClient)
191
200
  });
192
201
  const rowActions = useCrudListRowActions(listRowActions, {
193
202
  resolveRecordId: (record, index) => records.resolveRowKey(record, index),
194
- resolveContext: () => ({
195
- records,
196
- reload: records.reload
197
- })
203
+ resolveContext: () => buildCrudListActionContext(records, crudHttpClient)
198
204
  });
199
205
  const listPrimaryAction = computed(() =>
200
206
  newUrlTemplate ? records.resolveParams(newUrlTemplate) : ""
@@ -224,4 +230,8 @@ function useCrudListScreen({
224
230
  });
225
231
  }
226
232
 
227
- export { useCrudListScreen };
233
+ const __testables = Object.freeze({
234
+ buildCrudListActionContext
235
+ });
236
+
237
+ export { __testables, useCrudListScreen };
@@ -7,6 +7,10 @@ export { default as CrudListBulkActionSurface } from "./components/CrudListBulkA
7
7
  export { default as CrudListFilterSurface } from "./components/CrudListFilterSurface.vue";
8
8
  export { default as CrudListScreen } from "./components/CrudListScreen.vue";
9
9
  export { default as CrudViewScreen } from "./components/CrudViewScreen.vue";
10
+ export {
11
+ normalizeCrudApiAccess,
12
+ resolveCrudHttpClient
13
+ } from "./composables/crud/crudHttpClientSupport.js";
10
14
 
11
15
  const clientProviders = Object.freeze([UsersWebClientProvider]);
12
16
 
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ normalizeCrudApiAccess,
6
+ resolveCrudHttpClient
7
+ } from "../src/client/composables/crud/crudHttpClientSupport.js";
8
+ import {
9
+ __testables as crudListScreenTestables
10
+ } from "../src/client/composables/useCrudListScreen.js";
11
+
12
+ test("resolveCrudHttpClient injects per-request CSRF opt-out for public resources", async () => {
13
+ const calls = [];
14
+ const configuredClient = {
15
+ async request(url, options) {
16
+ calls.push([url, options]);
17
+ return { ok: true };
18
+ }
19
+ };
20
+ const client = resolveCrudHttpClient({ apiAccess: "public" }, {
21
+ client: configuredClient
22
+ });
23
+
24
+ for (const method of ["POST", "PATCH", "DELETE"]) {
25
+ await client.request("/api/books/1", {
26
+ method,
27
+ csrf: true
28
+ });
29
+ }
30
+
31
+ assert.deepEqual(
32
+ calls.map(([url, options]) => [url, options.method, options.csrf]),
33
+ [
34
+ ["/api/books/1", "POST", false],
35
+ ["/api/books/1", "PATCH", false],
36
+ ["/api/books/1", "DELETE", false]
37
+ ]
38
+ );
39
+ });
40
+
41
+ test("resolveCrudHttpClient preserves the configured client for authenticated resources", () => {
42
+ const configuredClient = {
43
+ request() {}
44
+ };
45
+
46
+ assert.equal(
47
+ resolveCrudHttpClient({ apiAccess: "authenticated" }, { client: configuredClient }),
48
+ configuredClient
49
+ );
50
+ assert.equal(
51
+ resolveCrudHttpClient({}, { client: configuredClient }),
52
+ configuredClient
53
+ );
54
+ assert.equal(normalizeCrudApiAccess(""), "authenticated");
55
+ assert.throws(
56
+ () => resolveCrudHttpClient({ apiAccess: "always" }, { client: configuredClient }),
57
+ /apiAccess must be one of: authenticated, public/
58
+ );
59
+ });
60
+
61
+ test("CRUD list row and bulk action context carries the resource-aware client", () => {
62
+ const records = {
63
+ reload() {}
64
+ };
65
+ const client = {
66
+ request() {}
67
+ };
68
+
69
+ assert.deepEqual(
70
+ crudListScreenTestables.buildCrudListActionContext(records, client),
71
+ {
72
+ records,
73
+ reload: records.reload,
74
+ client
75
+ }
76
+ );
77
+ });
@@ -39,6 +39,7 @@ test("users-web exports are explicit and aligned with production/template usage"
39
39
  "./client/composables/useCrudListFilters",
40
40
  "./client/composables/useCrudList",
41
41
  "./client/composables/useCrudListScreen",
42
+ "./client/crudHttpClient",
42
43
  "./client/composables/useCrudView",
43
44
  "./client/composables/useCrudViewScreen",
44
45
  "./client/lib/httpClient"
@@ -403,3 +403,55 @@ test("commands use the configured users-web HTTP client by default", async () =>
403
403
  resetUsersWebHttpClientForTests();
404
404
  }
405
405
  });
406
+
407
+ test("commands use public CRUD resource access without a CSRF session preflight", async () => {
408
+ const queryClient = new QueryClient();
409
+ const calls = [];
410
+ let command = null;
411
+ configureUsersWebHttpClient({
412
+ fetchImpl: async (url, options) => {
413
+ calls.push([url, options]);
414
+ return {
415
+ ok: true,
416
+ status: 204,
417
+ headers: {
418
+ get() {
419
+ return "";
420
+ }
421
+ },
422
+ async json() {
423
+ return {};
424
+ }
425
+ };
426
+ }
427
+ });
428
+
429
+ try {
430
+ const app = createSSRApp({
431
+ setup() {
432
+ command = useCommand({
433
+ access: "always",
434
+ apiSuffix: "/books/1",
435
+ writeMethod: "DELETE",
436
+ resource: {
437
+ apiAccess: "public"
438
+ }
439
+ });
440
+ return () => h("div");
441
+ }
442
+ });
443
+ app.use(VueQueryPlugin, {
444
+ queryClient
445
+ });
446
+ await renderToString(app);
447
+
448
+ await command.run();
449
+
450
+ assert.equal(calls.length, 1);
451
+ assert.equal(calls[0][0], "/api/books/1");
452
+ assert.equal(calls[0][1].method, "DELETE");
453
+ assert.equal(Object.hasOwn(calls[0][1], "csrf"), false);
454
+ } finally {
455
+ resetUsersWebHttpClientForTests();
456
+ }
457
+ });
@@ -108,6 +108,35 @@ test("buildCrudFormPayload and applyCrudPayloadToForm round-trip date-time field
108
108
  assert.equal(form.scheduledAt, "2024-01-02T03:04");
109
109
  });
110
110
 
111
+ test("applyCrudPayloadToForm normalizes date values for HTML date inputs", () => {
112
+ const fields = [
113
+ { key: "publishedOn", type: "string", format: "date" },
114
+ { key: "reviewedOn", type: "string", format: "date" },
115
+ { key: "archivedOn", type: "string", format: "date" },
116
+ { key: "invalidOn", type: "string", format: "date" }
117
+ ];
118
+ const form = reactive({
119
+ publishedOn: "",
120
+ reviewedOn: "",
121
+ archivedOn: "",
122
+ invalidOn: ""
123
+ });
124
+
125
+ applyCrudPayloadToForm(fields, form, {
126
+ publishedOn: "2026-08-10",
127
+ reviewedOn: "2026-08-11T00:00:00.000Z",
128
+ archivedOn: new Date("2026-08-12T00:00:00.000Z"),
129
+ invalidOn: "not-a-date"
130
+ });
131
+
132
+ assert.deepEqual(form, {
133
+ publishedOn: "2026-08-10",
134
+ reviewedOn: "2026-08-11",
135
+ archivedOn: "2026-08-12",
136
+ invalidOn: "not-a-date"
137
+ });
138
+ });
139
+
111
140
  test("buildCrudFormPayload normalizes time fields to canonical HH:MM", () => {
112
141
  const fields = [
113
142
  { key: "fromTime", type: "string", format: "time" },