@7365admin1/layer-common 3.2.2-staging.197 → 3.2.2-staging.199
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/components/AttendanceSettingsDialog.vue +17 -0
- package/components/HidReaderForm.vue +8 -0
- package/components/HidReaderManagement.vue +1 -0
- package/components/ServiceProviderMain.vue +27 -2
- package/composables/useHidAmico.ts +39 -5
- package/package.json +1 -1
- package/utils/hid-discover.test.ts +102 -0
|
@@ -169,6 +169,7 @@ const {
|
|
|
169
169
|
data: getAttendanceSettingsReq,
|
|
170
170
|
refresh: getAttendanceSettingsRefresh,
|
|
171
171
|
pending: loading,
|
|
172
|
+
error: getAttendanceSettingsError,
|
|
172
173
|
} = await useLazyAsyncData(
|
|
173
174
|
`attendance-settings-${props.site}`,
|
|
174
175
|
() => getAttendanceSettings(props.site, props.serviceType),
|
|
@@ -179,6 +180,22 @@ const {
|
|
|
179
180
|
);
|
|
180
181
|
|
|
181
182
|
watchEffect(() => {
|
|
183
|
+
// A failed read used to be indistinguishable from a site with no settings
|
|
184
|
+
// saved: the dialog just opened with geofencing off and blank fields, and
|
|
185
|
+
// the operator had no way to tell it was showing them nothing rather than
|
|
186
|
+
// the truth. Worse, a request answered by the SPA fallback resolves with a
|
|
187
|
+
// 200 and a STRING, so nothing threw - every field read off it was simply
|
|
188
|
+
// undefined. Anything that is not an object is a failure, and it is said.
|
|
189
|
+
const res: any = getAttendanceSettingsReq.value;
|
|
190
|
+
if (
|
|
191
|
+
getAttendanceSettingsError.value ||
|
|
192
|
+
(res !== null && res !== undefined && typeof res !== "object")
|
|
193
|
+
) {
|
|
194
|
+
error.value =
|
|
195
|
+
"Could not load the saved settings - what you see below is not what is saved. Close and reopen to try again.";
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
182
199
|
if (getAttendanceSettingsReq.value) {
|
|
183
200
|
isGeofencingEnabled.value =
|
|
184
201
|
getAttendanceSettingsReq.value.isGeofencingEnabled || false;
|
|
@@ -153,6 +153,13 @@ const props = defineProps({
|
|
|
153
153
|
type: Boolean,
|
|
154
154
|
default: false,
|
|
155
155
|
},
|
|
156
|
+
// The site this reader belongs to. Passed down from HidReaderManagement,
|
|
157
|
+
// which already has it as a prop from the page's route. Reader discovery is
|
|
158
|
+
// authorized against it server-side.
|
|
159
|
+
site: {
|
|
160
|
+
type: String,
|
|
161
|
+
default: "",
|
|
162
|
+
},
|
|
156
163
|
});
|
|
157
164
|
|
|
158
165
|
const emit = defineEmits<{
|
|
@@ -236,6 +243,7 @@ async function connectReader() {
|
|
|
236
243
|
baseUrl: draft.baseUrl.trim(),
|
|
237
244
|
username: draft.username.trim(),
|
|
238
245
|
password: draft.password,
|
|
246
|
+
site: props.site,
|
|
239
247
|
});
|
|
240
248
|
const nestedData = response?.data;
|
|
241
249
|
const data = typeof nestedData === "object" && nestedData !== null
|
|
@@ -208,6 +208,16 @@
|
|
|
208
208
|
</v-list>
|
|
209
209
|
</v-menu>
|
|
210
210
|
</template>
|
|
211
|
+
<!--
|
|
212
|
+
"There are none" and "we could not fetch them" are not the same
|
|
213
|
+
sentence, and this table drew the first for both: a failed request
|
|
214
|
+
left `items` at [] and the default no-data row read "No data
|
|
215
|
+
available". That is what hid the Pending tab being unreachable in
|
|
216
|
+
six apps for months. `loadFailed` splits them.
|
|
217
|
+
-->
|
|
218
|
+
<template #no-data>
|
|
219
|
+
<DashboardEmptyState :error="loadFailed" />
|
|
220
|
+
</template>
|
|
211
221
|
</v-data-table>
|
|
212
222
|
</div>
|
|
213
223
|
|
|
@@ -812,6 +822,8 @@ const messageColor = ref("");
|
|
|
812
822
|
|
|
813
823
|
const items = ref<TableRow[]>([]);
|
|
814
824
|
const loading = ref(false);
|
|
825
|
+
/** The last load failed, as opposed to genuinely returning nothing. */
|
|
826
|
+
const loadFailed = ref(false);
|
|
815
827
|
|
|
816
828
|
const validServiceProvider = ref(false);
|
|
817
829
|
const disableServiceProvider = ref(false);
|
|
@@ -1244,6 +1256,18 @@ async function loadList() {
|
|
|
1244
1256
|
// }
|
|
1245
1257
|
|
|
1246
1258
|
loading.value = true;
|
|
1259
|
+
loadFailed.value = false;
|
|
1260
|
+
// A request that never reaches the API can still resolve: a path with no
|
|
1261
|
+
// proxy rule is answered by the SPA fallback with 200 text/html, and ofetch
|
|
1262
|
+
// hands that back as a STRING. `.items` on a string is undefined, so
|
|
1263
|
+
// `?? []` used to turn that into a clean empty table. Anything that is not
|
|
1264
|
+
// an array is a failure, not a zero - say so.
|
|
1265
|
+
const rowsOf = (data: any) => {
|
|
1266
|
+
if (!Array.isArray(data?.items)) {
|
|
1267
|
+
throw new Error("The server did not return a list. Please try again.");
|
|
1268
|
+
}
|
|
1269
|
+
return data.items;
|
|
1270
|
+
};
|
|
1247
1271
|
try {
|
|
1248
1272
|
if (listTab.value === "pending") {
|
|
1249
1273
|
// Everything that is not a finished engagement, so a rejection or a
|
|
@@ -1256,7 +1280,7 @@ async function loadList() {
|
|
|
1256
1280
|
search: searchText.value?.trim() || "",
|
|
1257
1281
|
limit: pageSize,
|
|
1258
1282
|
});
|
|
1259
|
-
items.value = mapInviteRows(data
|
|
1283
|
+
items.value = mapInviteRows(rowsOf(data));
|
|
1260
1284
|
pages.value = data.pages ?? 0;
|
|
1261
1285
|
pageRange.value = data.pageRange ?? "-- - -- of --";
|
|
1262
1286
|
return;
|
|
@@ -1270,10 +1294,11 @@ async function loadList() {
|
|
|
1270
1294
|
limit: pageSize,
|
|
1271
1295
|
status: statusParam,
|
|
1272
1296
|
});
|
|
1273
|
-
items.value = mapProviderRows(data
|
|
1297
|
+
items.value = mapProviderRows(rowsOf(data));
|
|
1274
1298
|
pages.value = data.pages ?? 0;
|
|
1275
1299
|
pageRange.value = data.pageRange ?? "-- - -- of --";
|
|
1276
1300
|
} catch (error: any) {
|
|
1301
|
+
loadFailed.value = true;
|
|
1277
1302
|
showMessage(errorConverter(error), "error");
|
|
1278
1303
|
items.value = [];
|
|
1279
1304
|
pages.value = 0;
|
|
@@ -90,6 +90,23 @@ export type HidSipAccountData = {
|
|
|
90
90
|
provisionedAt: string;
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* True only for the specific validation error an API that predates core #1878
|
|
95
|
+
* returns when the discover body carries `site`. Deliberately narrow: after
|
|
96
|
+
* #1878 `site` is required, so this message can no longer be produced and the
|
|
97
|
+
* fallback below can never hide a real authorization refusal.
|
|
98
|
+
*/
|
|
99
|
+
export function isUnknownSiteFieldError(error: unknown): boolean {
|
|
100
|
+
const record = typeof error === "object" && error !== null
|
|
101
|
+
? error as Record<string, unknown>
|
|
102
|
+
: {};
|
|
103
|
+
const data = typeof record.data === "object" && record.data !== null
|
|
104
|
+
? record.data as Record<string, unknown>
|
|
105
|
+
: {};
|
|
106
|
+
const message = String(data.message || record.message || "");
|
|
107
|
+
return message.includes("\"site\"") && message.includes("not allowed");
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
export default function useHidAmico() {
|
|
94
111
|
const basePath = "/api/access-management/hid";
|
|
95
112
|
|
|
@@ -260,11 +277,28 @@ export default function useHidAmico() {
|
|
|
260
277
|
);
|
|
261
278
|
}
|
|
262
279
|
|
|
263
|
-
function discoverReader(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
280
|
+
async function discoverReader(
|
|
281
|
+
payload: Pick<HidReaderPayload, "baseUrl" | "username" | "password" | "site">,
|
|
282
|
+
) {
|
|
283
|
+
const post = (body: Record<string, unknown>) =>
|
|
284
|
+
useNuxtApp().$api<Record<string, unknown>>(`${basePath}/readers/discover`, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
body,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
return await post(payload as Record<string, unknown>);
|
|
291
|
+
} catch (error: unknown) {
|
|
292
|
+
// The discover endpoint only started accepting (and requiring) `site` in
|
|
293
|
+
// core #1878. An API that predates it validates the body with Joi's
|
|
294
|
+
// default allowUnknown:false and answers `"site" is not allowed`, so the
|
|
295
|
+
// web layer would break the moment it shipped ahead of the API. Retry once
|
|
296
|
+
// without `site` so both API versions work.
|
|
297
|
+
// ponytail: remove this fallback once core #1878 is deployed everywhere.
|
|
298
|
+
if (!isUnknownSiteFieldError(error)) throw error;
|
|
299
|
+
const { site: _site, ...legacy } = payload;
|
|
300
|
+
return await post(legacy as Record<string, unknown>);
|
|
301
|
+
}
|
|
268
302
|
}
|
|
269
303
|
|
|
270
304
|
function getUserPinStatus(readerId: string, hidUserId: string | number) {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "3.2.2-staging.
|
|
5
|
+
"version": "3.2.2-staging.199",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import useHidAmico, { isUnknownSiteFieldError } from "../composables/useHidAmico.ts";
|
|
8
|
+
|
|
9
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
|
|
11
|
+
type Call = { url: string; body: Record<string, unknown> };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The composable calls the Nuxt auto-import `useNuxtApp().$api(...)`. Install a
|
|
15
|
+
* stub for it so the request body can be read without a Nuxt app.
|
|
16
|
+
*/
|
|
17
|
+
function stubApi(handler: (call: Call) => unknown) {
|
|
18
|
+
const calls: Call[] = [];
|
|
19
|
+
(globalThis as Record<string, unknown>).useNuxtApp = () => ({
|
|
20
|
+
$api: (url: string, options: { body: Record<string, unknown> }) => {
|
|
21
|
+
const call = { url, body: options.body };
|
|
22
|
+
calls.push(call);
|
|
23
|
+
return Promise.resolve(handler(call));
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
return calls;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fetchError(message: string) {
|
|
30
|
+
return Object.assign(new Error("400 Bad Request"), { data: { message } });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("discoverReader sends site in the request body", async () => {
|
|
34
|
+
const calls = stubApi(() => ({ data: { deviceId: "D1", portals: [] } }));
|
|
35
|
+
|
|
36
|
+
await useHidAmico().discoverReader({
|
|
37
|
+
baseUrl: "https://reader.example",
|
|
38
|
+
username: "operator",
|
|
39
|
+
password: "secret",
|
|
40
|
+
site: "0123456789abcdef01234567",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.equal(calls.length, 1);
|
|
44
|
+
assert.equal(calls[0].url, "/api/access-management/hid/readers/discover");
|
|
45
|
+
assert.equal(calls[0].body.site, "0123456789abcdef01234567");
|
|
46
|
+
assert.equal(calls[0].body.baseUrl, "https://reader.example");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("discoverReader retries without site when the API predates core #1878", async () => {
|
|
50
|
+
const calls = stubApi((call) => {
|
|
51
|
+
if ("site" in call.body) throw fetchError('"site" is not allowed');
|
|
52
|
+
return { data: { deviceId: "D1", portals: [] } };
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const response = await useHidAmico().discoverReader({
|
|
56
|
+
baseUrl: "https://reader.example",
|
|
57
|
+
username: "operator",
|
|
58
|
+
password: "secret",
|
|
59
|
+
site: "0123456789abcdef01234567",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
assert.equal(calls.length, 2);
|
|
63
|
+
assert.equal("site" in calls[1].body, false);
|
|
64
|
+
assert.equal(calls[1].body.baseUrl, "https://reader.example");
|
|
65
|
+
assert.deepEqual(response, { data: { deviceId: "D1", portals: [] } });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("discoverReader does not retry a real refusal", async () => {
|
|
69
|
+
const calls = stubApi(() => {
|
|
70
|
+
throw fetchError("You are not authorized to manage readers for this site.");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await assert.rejects(
|
|
74
|
+
useHidAmico().discoverReader({
|
|
75
|
+
baseUrl: "https://reader.example",
|
|
76
|
+
username: "operator",
|
|
77
|
+
password: "secret",
|
|
78
|
+
site: "0123456789abcdef01234567",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
assert.equal(calls.length, 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("isUnknownSiteFieldError only matches the unknown-key message", () => {
|
|
85
|
+
assert.equal(isUnknownSiteFieldError(fetchError('"site" is not allowed')), true);
|
|
86
|
+
assert.equal(isUnknownSiteFieldError(new Error('"site" is not allowed')), true);
|
|
87
|
+
assert.equal(isUnknownSiteFieldError(fetchError('"site" is required')), false);
|
|
88
|
+
assert.equal(isUnknownSiteFieldError(fetchError('"baseUrl" is not allowed')), false);
|
|
89
|
+
assert.equal(isUnknownSiteFieldError(fetchError("Unauthorized")), false);
|
|
90
|
+
assert.equal(isUnknownSiteFieldError(null), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("the reader form takes site as a prop and puts it in the discover call", () => {
|
|
94
|
+
const source = readFileSync(join(root, "components", "HidReaderForm.vue"), "utf8");
|
|
95
|
+
assert.match(source, /site:\s*\{\s*type:\s*String/);
|
|
96
|
+
assert.match(source, /discoverReader\(\{[\s\S]*?site:\s*props\.site[\s\S]*?\}\)/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("reader management passes its site prop down to the form", () => {
|
|
100
|
+
const source = readFileSync(join(root, "components", "HidReaderManagement.vue"), "utf8");
|
|
101
|
+
assert.match(source, /<HidReaderForm[\s\S]*?:site="site"[\s\S]*?\/>/);
|
|
102
|
+
});
|