@7365admin1/layer-common 4.0.1 → 4.0.3-staging.220
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/CHANGELOG.md +101 -0
- package/components/AccessCardQrTagging.vue +0 -4
- package/components/AccessManagement.vue +0 -4
- package/components/BuildingManagement/buildings.vue +0 -4
- package/components/BulletinBoardManagement.vue +0 -3
- package/components/CameraForm.vue +2 -5
- package/components/CameraMain.vue +3 -6
- package/components/ClientDetailForm.vue +65 -5
- package/components/ClientMain.vue +201 -59
- package/components/DashboardMain.vue +27 -8
- package/components/DocumentManagement.vue +0 -4
- package/components/HidAccessLogDashboard.vue +114 -34
- package/components/HidReaderManagement.vue +34 -11
- package/components/HidReaderUserRoster.vue +376 -0
- package/components/HidUserEnrollment.vue +6 -0
- package/components/HidUserMapping.vue +583 -0
- package/components/IncidentReport/Authorities.vue +0 -4
- package/components/IncidentReport/IncidentInformation.vue +0 -4
- package/components/IncidentReport/IncidentInformationDownload.vue +0 -4
- package/components/IncidentReport/affectedEntities.vue +0 -4
- package/components/MemberMain.vue +20 -2
- package/components/RolePermissionFormCreate.vue +6 -1
- package/components/RolePermissionFormPreviewUpdate.vue +22 -11
- package/components/RolePermissionMain.vue +39 -4
- package/components/VehicleManagement.vue +0 -4
- package/components/VisitorManagement.vue +0 -3
- package/components/VisitorsReportPreview.vue +0 -3
- package/composables/useBulletinBoardPermission.ts +6 -1
- package/composables/useConsoleTier.ts +73 -0
- package/composables/useHidAmico.ts +18 -1
- package/composables/useHidNavigation.ts +8 -1
- package/composables/useMember.ts +0 -5
- package/composables/useSettingsPermission.ts +7 -1
- package/package.json +2 -2
- package/pages/[org]/[site]/access-mgmt/hid-user-mapping/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-users/index.vue +1 -1
- package/utils/client-subscription.test.ts +95 -0
- package/utils/console-tier.test.ts +87 -0
- package/utils/console-tier.ts +67 -0
- package/utils/data.test.ts +84 -0
- package/utils/data.ts +92 -0
|
@@ -99,15 +99,12 @@
|
|
|
99
99
|
</div>
|
|
100
100
|
</v-col>
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
</v-col>
|
|
109
|
-
|
|
110
|
-
<v-col cols="12" class="my-2">
|
|
102
|
+
<!--
|
|
103
|
+
One message, once. This block used to appear twice -- inside
|
|
104
|
+
`v-if="edit"` and again unconditionally -- so a refused save printed
|
|
105
|
+
the server's sentence back to back.
|
|
106
|
+
-->
|
|
107
|
+
<v-col v-if="message" cols="12" class="my-2">
|
|
111
108
|
<v-row no-gutters>
|
|
112
109
|
<v-col cols="12" class="text-center">
|
|
113
110
|
<span class="role-perm__error">{{ message }}</span>
|
|
@@ -122,7 +119,7 @@
|
|
|
122
119
|
<AppButton v-if="!edit" variant="ghost" @click="emit('cancel')">Close</AppButton>
|
|
123
120
|
<AppButton v-else variant="ghost" @click="edit = false">Cancel</AppButton>
|
|
124
121
|
|
|
125
|
-
<AppButton v-if="!edit" :disabled="disable" @click="edit = true">Edit</AppButton>
|
|
122
|
+
<AppButton v-if="!edit && canUpdateRole" :disabled="disable" @click="edit = true">Edit</AppButton>
|
|
126
123
|
<AppButton v-else :disabled="Boolean(errorMessages) || disable" @click="submit()">
|
|
127
124
|
Submit
|
|
128
125
|
</AppButton>
|
|
@@ -159,6 +156,15 @@ const props = defineProps({
|
|
|
159
156
|
type: Array as PropType<string[]>,
|
|
160
157
|
default: () => [],
|
|
161
158
|
},
|
|
159
|
+
// "Update role". This dialog had no permission prop at all, so it showed Edit
|
|
160
|
+
// to everyone -- including on the seeded platform-owner role -- and the
|
|
161
|
+
// `canUpdateRole` the apps have been passing to `RolePermissionMain` in good
|
|
162
|
+
// faith did nothing. `RolePermissionMain` is the only thing that renders this
|
|
163
|
+
// dialog and always passes it, so the default is closed.
|
|
164
|
+
canUpdateRole: {
|
|
165
|
+
type: Boolean,
|
|
166
|
+
default: false,
|
|
167
|
+
},
|
|
162
168
|
});
|
|
163
169
|
|
|
164
170
|
const validForm = ref(false);
|
|
@@ -282,7 +288,12 @@ async function submit() {
|
|
|
282
288
|
await updatePermissionById(props.id, definedModel.value);
|
|
283
289
|
emit("success");
|
|
284
290
|
} catch (error: any) {
|
|
285
|
-
|
|
291
|
+
// `error.response` is undefined on a network or timeout failure, so the old
|
|
292
|
+
// unguarded chain threw inside its own catch: nothing was shown, the button
|
|
293
|
+
// came back enabled, and the save looked like it had simply been ignored.
|
|
294
|
+
message.value =
|
|
295
|
+
error?.response?._data?.message ??
|
|
296
|
+
"Could not save this role. Please check your connection and try again.";
|
|
286
297
|
} finally {
|
|
287
298
|
disable.value = false;
|
|
288
299
|
}
|
|
@@ -42,12 +42,13 @@
|
|
|
42
42
|
|
|
43
43
|
<v-data-table
|
|
44
44
|
:headers="props.headers"
|
|
45
|
-
:items="
|
|
45
|
+
:items="visibleItems"
|
|
46
46
|
item-value="_id"
|
|
47
47
|
items-per-page="20"
|
|
48
48
|
fixed-header
|
|
49
49
|
hide-default-footer
|
|
50
50
|
hide-default-header
|
|
51
|
+
:no-data-text="emptyText"
|
|
51
52
|
@click:row="tableRowClickHandler"
|
|
52
53
|
style="max-height: calc(100vh - (180px))"
|
|
53
54
|
>
|
|
@@ -102,6 +103,7 @@
|
|
|
102
103
|
v-model:edit="edit"
|
|
103
104
|
:name="name"
|
|
104
105
|
:id="roleId"
|
|
106
|
+
:can-update-role="props.canUpdateRole"
|
|
105
107
|
:web-only-resources="props.webOnlyResources"
|
|
106
108
|
/>
|
|
107
109
|
</v-dialog>
|
|
@@ -245,13 +247,18 @@ const props = defineProps({
|
|
|
245
247
|
type: Boolean,
|
|
246
248
|
default: false,
|
|
247
249
|
},
|
|
250
|
+
// "See all roles". Defaults to `true` because nine of the ten pages that
|
|
251
|
+
// render this component do not pass it, and until now nothing read it at all
|
|
252
|
+
// -- so `true` is exactly what those pages have always had. The one page that
|
|
253
|
+
// does pass it now gets a gate that works.
|
|
248
254
|
canViewRole: {
|
|
249
255
|
type: Boolean,
|
|
250
|
-
default:
|
|
256
|
+
default: true,
|
|
251
257
|
},
|
|
258
|
+
// "See role details" -- opening a role. Same reasoning as `canViewRole`.
|
|
252
259
|
canViewByRole: {
|
|
253
260
|
type: Boolean,
|
|
254
|
-
default:
|
|
261
|
+
default: true,
|
|
255
262
|
},
|
|
256
263
|
canDeleteRole: {
|
|
257
264
|
type: Boolean,
|
|
@@ -281,6 +288,7 @@ const {
|
|
|
281
288
|
data: getRoleReq,
|
|
282
289
|
refresh: getRoles,
|
|
283
290
|
status: getRoleReqStatus,
|
|
291
|
+
error: getRoleReqError,
|
|
284
292
|
} = useLazyAsyncData(
|
|
285
293
|
"roles-permissions-get-all",
|
|
286
294
|
() =>
|
|
@@ -305,7 +313,33 @@ watchEffect(() => {
|
|
|
305
313
|
}
|
|
306
314
|
});
|
|
307
315
|
|
|
316
|
+
// "See all roles". Without it the list is not drawn at all, and `emptyText`
|
|
317
|
+
// below says so rather than leaving a blank table to be read as a failure.
|
|
318
|
+
const visibleItems = computed(() => (props.canViewRole ? items.value : []));
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* SAY WHY THE TABLE IS EMPTY. "No data available" was shown for three
|
|
322
|
+
* different situations -- you may not see roles, the request failed, and this
|
|
323
|
+
* organisation genuinely has none -- so a refusal was indistinguishable from an
|
|
324
|
+
* empty list and neither was distinguishable from a broken screen.
|
|
325
|
+
*/
|
|
326
|
+
const emptyText = computed(() => {
|
|
327
|
+
if (!props.canViewRole) return "You do not have permission to view roles.";
|
|
328
|
+
const status = (getRoleReqError.value as any)?.response?.status
|
|
329
|
+
?? (getRoleReqError.value as any)?.statusCode;
|
|
330
|
+
if (status === 401 || status === 403) {
|
|
331
|
+
return "You do not have permission to view the roles for this organisation.";
|
|
332
|
+
}
|
|
333
|
+
if (getRoleReqError.value) {
|
|
334
|
+
return "Could not load roles. Please refresh to try again.";
|
|
335
|
+
}
|
|
336
|
+
return "No roles have been created yet.";
|
|
337
|
+
});
|
|
338
|
+
|
|
308
339
|
function tableRowClickHandler(_: any, data: any) {
|
|
340
|
+
// "See role details". Ungated until now, which is why granting or withholding
|
|
341
|
+
// it changed nothing.
|
|
342
|
+
if (!props.canViewByRole) return;
|
|
309
343
|
previewDialog.value = true;
|
|
310
344
|
roleId.value = data.item._id;
|
|
311
345
|
}
|
|
@@ -348,7 +382,8 @@ const { getRoleById: _getRoleById, deleteRole } = useRole();
|
|
|
348
382
|
|
|
349
383
|
const { data: role, refresh: getRoleById } = useLazyAsyncData(
|
|
350
384
|
"role-permissions-get-by-id",
|
|
351
|
-
() => _getRoleById(roleId.value)
|
|
385
|
+
() => _getRoleById(roleId.value),
|
|
386
|
+
{ immediate: false }
|
|
352
387
|
);
|
|
353
388
|
|
|
354
389
|
watchEffect(() => {
|
|
@@ -2,7 +2,12 @@ import { useCommonPermissions } from "./useCommonPermission";
|
|
|
2
2
|
|
|
3
3
|
export function useBulletinBoardPermission() {
|
|
4
4
|
const { hasPermission } = usePermission();
|
|
5
|
-
|
|
5
|
+
// Same nesting rule as every other permission composable: `hasPermission`
|
|
6
|
+
// reads `catalogue[resource][action]`, and `bulletinBoardPermissions` is a
|
|
7
|
+
// flat `{action: ...}` map. Unwrapped, `catalogue["bulletin-board"]` was
|
|
8
|
+
// undefined and all five gates were reachable only through `"*"`.
|
|
9
|
+
const { bulletinBoardPermissions } = useCommonPermissions();
|
|
10
|
+
const permissions: TPermissions = { "bulletin-board": bulletinBoardPermissions };
|
|
6
11
|
|
|
7
12
|
const { userAppRole } = useLocalSetup();
|
|
8
13
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import { useCookie, useRuntimeConfig, useState } from "#app";
|
|
3
|
+
|
|
4
|
+
import useMember from "./useMember";
|
|
5
|
+
import useRole from "./useRole";
|
|
6
|
+
import { consoleTier, type TConsoleTier } from "../utils/console-tier";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* WHICH SEVEN365 TIER THE SIGNED-IN PERSON IS - fetched once, shared, and
|
|
10
|
+
* used ONLY to decide what to draw.
|
|
11
|
+
*
|
|
12
|
+
* The rule lives in `utils/console-tier.ts` and mirrors the server's
|
|
13
|
+
* `isPlatformOwner`. Read that file for why `role.default` is the marker, and
|
|
14
|
+
* for the honest limit: the server re-decides this on every write from the
|
|
15
|
+
* session, so a browser that flips this boolean gets a visible button and a
|
|
16
|
+
* 401. Nothing here is a permission check.
|
|
17
|
+
*
|
|
18
|
+
* Both requests hit endpoints the console already uses, so no new API surface
|
|
19
|
+
* and no proxy rule was needed:
|
|
20
|
+
*
|
|
21
|
+
* GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
22
|
+
* GET /api/roles/id/:role that membership's role document
|
|
23
|
+
*
|
|
24
|
+
* `admin` is the membership type deliberately, not the org app's own `APP`.
|
|
25
|
+
* A Seven365 person can also hold an ordinary organisation membership - and on
|
|
26
|
+
* staging that one carries a role merely NAMED "Super Admin" - so asking for
|
|
27
|
+
* the org membership would read the wrong row and answer the wrong tier.
|
|
28
|
+
*/
|
|
29
|
+
export default function useConsoleTier() {
|
|
30
|
+
const { cookieConfig } = useRuntimeConfig().public;
|
|
31
|
+
|
|
32
|
+
const tier = useState<TConsoleTier>("consoleTier", () => "none");
|
|
33
|
+
// Separate from `tier` because "not looked yet" and "looked, not staff" are
|
|
34
|
+
// both `none` on the glass but only one of them should be re-tried.
|
|
35
|
+
const resolved = useState<boolean>("consoleTierResolved", () => false);
|
|
36
|
+
|
|
37
|
+
const { getByUserIdType } = useMember();
|
|
38
|
+
const { getRoleById } = useRole();
|
|
39
|
+
|
|
40
|
+
async function load(force = false): Promise<TConsoleTier> {
|
|
41
|
+
if (resolved.value && !force) return tier.value;
|
|
42
|
+
|
|
43
|
+
const user = useCookie("user", cookieConfig).value as string | null;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
if (!user) {
|
|
47
|
+
tier.value = "none";
|
|
48
|
+
return tier.value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const member = await getByUserIdType(user, "admin");
|
|
52
|
+
const role = member?.role ? await getRoleById(member.role as string) : null;
|
|
53
|
+
|
|
54
|
+
tier.value = consoleTier(member, role);
|
|
55
|
+
} catch {
|
|
56
|
+
// Fails closed. A 404 here is the ordinary answer for somebody who is
|
|
57
|
+
// not Seven365 staff at all, so it is not worth a console error.
|
|
58
|
+
tier.value = "none";
|
|
59
|
+
} finally {
|
|
60
|
+
resolved.value = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return tier.value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
tier,
|
|
68
|
+
resolved,
|
|
69
|
+
isOwner: computed(() => tier.value === "owner"),
|
|
70
|
+
isStaff: computed(() => tier.value === "owner" || tier.value === "staff"),
|
|
71
|
+
load,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -101,6 +101,14 @@ type HidFacialEnrollmentResult = {
|
|
|
101
101
|
errors?: Array<{ code?: number; message?: string }>;
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
+
export type HidFacialSyncResult = {
|
|
105
|
+
readerId: string;
|
|
106
|
+
facialDetected: number;
|
|
107
|
+
syncedCount: number;
|
|
108
|
+
unmappedCount: number;
|
|
109
|
+
message: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
104
112
|
export type HidPhysicalCardType = "pacs" | "csn";
|
|
105
113
|
|
|
106
114
|
export type HidPhysicalCard = {
|
|
@@ -191,6 +199,13 @@ export default function useHidAmico() {
|
|
|
191
199
|
});
|
|
192
200
|
}
|
|
193
201
|
|
|
202
|
+
function syncReaderFacialData(readerId: string) {
|
|
203
|
+
return useNuxtApp().$api<{ data: HidFacialSyncResult }>(
|
|
204
|
+
`${basePath}/readers/${readerId}/facial-sync`,
|
|
205
|
+
{ method: "POST" },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
194
209
|
function configureReaderIntegration(readerId: string) {
|
|
195
210
|
return useNuxtApp().$api<HidApiRecord>(`${basePath}/readers/${readerId}/configure`, {
|
|
196
211
|
method: "POST",
|
|
@@ -254,6 +269,7 @@ export default function useHidAmico() {
|
|
|
254
269
|
limit?: number;
|
|
255
270
|
search?: string;
|
|
256
271
|
status?: "mapped" | "unmapped" | "";
|
|
272
|
+
includeVisitors?: boolean;
|
|
257
273
|
} = {}) {
|
|
258
274
|
return useNuxtApp().$api<HidCollectionResponse>(`${basePath}/readers/${readerId}/users`, {
|
|
259
275
|
method: "GET",
|
|
@@ -267,7 +283,7 @@ export default function useHidAmico() {
|
|
|
267
283
|
search?: string;
|
|
268
284
|
status?: "authorized" | "not_authorized" | "unknown" | "";
|
|
269
285
|
method?: "facial" | "qr_code" | "id_password" | "pin" | "card" | "";
|
|
270
|
-
tab?: "resident" | "visitor" | "administrator";
|
|
286
|
+
tab?: "all" | "unmapped" | "mapped" | "resident" | "visitor" | "administrator";
|
|
271
287
|
} = {}) {
|
|
272
288
|
return useNuxtApp().$api<HidCollectionResponse>(`${basePath}/readers/${readerId}/access-logs`, {
|
|
273
289
|
method: "GET",
|
|
@@ -499,6 +515,7 @@ export default function useHidAmico() {
|
|
|
499
515
|
deleteReader,
|
|
500
516
|
testReader,
|
|
501
517
|
syncReader,
|
|
518
|
+
syncReaderFacialData,
|
|
502
519
|
configureReaderIntegration,
|
|
503
520
|
setReaderMonitor,
|
|
504
521
|
setReaderOperatingMode,
|
|
@@ -23,12 +23,19 @@ export default function useHidNavigation() {
|
|
|
23
23
|
},
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
|
-
title: "HID Users",
|
|
26
|
+
title: "HID Reader Users",
|
|
27
27
|
route: {
|
|
28
28
|
name: "org-site-access-mgmt-hid-users",
|
|
29
29
|
params: { org, site },
|
|
30
30
|
},
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
title: "HID User Mapping",
|
|
34
|
+
route: {
|
|
35
|
+
name: "org-site-access-mgmt-hid-user-mapping",
|
|
36
|
+
params: { org, site },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
32
39
|
{
|
|
33
40
|
title: "HID Cards",
|
|
34
41
|
route: {
|
package/composables/useMember.ts
CHANGED
|
@@ -28,10 +28,6 @@ export default function useMember() {
|
|
|
28
28
|
function getAllByUserId(user: string) {
|
|
29
29
|
return useNuxtApp().$api<TMember>(`/api/members/users/${user}`);
|
|
30
30
|
}
|
|
31
|
-
function getByMemberId(user: string) {
|
|
32
|
-
return useNuxtApp().$api<TMember>(`/api//user/${user}`);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
31
|
function getByUserIdType(user: string, type: string) {
|
|
36
32
|
return useNuxtApp().$api<TMember>(`/api/members/user/${user}/app/${type}`);
|
|
37
33
|
}
|
|
@@ -165,7 +161,6 @@ export default function useMember() {
|
|
|
165
161
|
createUserByVerification,
|
|
166
162
|
createMemberInvite,
|
|
167
163
|
getByUserIdType,
|
|
168
|
-
getByMemberId,
|
|
169
164
|
updateMemberStatus,
|
|
170
165
|
updateMemberRole,
|
|
171
166
|
createMemberDirect,
|
|
@@ -2,7 +2,13 @@ import { useCommonPermissions } from "./useCommonPermission";
|
|
|
2
2
|
|
|
3
3
|
export function useSettingsPermission() {
|
|
4
4
|
const { hasPermission } = usePermission();
|
|
5
|
-
|
|
5
|
+
// `hasPermission` looks the action up as `catalogue[resource][action]`, so the
|
|
6
|
+
// catalogue has to be nested by resource. `siteSettingsPermissions` is a flat
|
|
7
|
+
// `{action: ...}` map, so passing it straight through made
|
|
8
|
+
// `catalogue["site-settings"]` undefined and every check below fell through to
|
|
9
|
+
// `false` -- only the `"*"` short-circuit could ever open a panel.
|
|
10
|
+
const { siteSettingsPermissions } = useCommonPermissions();
|
|
11
|
+
const permissions: TPermissions = { "site-settings": siteSettingsPermissions };
|
|
6
12
|
const { userAppRole } = useLocalSetup();
|
|
7
13
|
|
|
8
14
|
const canManageSiteInformation = computed(() => {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.0.
|
|
5
|
+
"version": "4.0.3-staging.220",
|
|
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.",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"build": "nuxt build .playground",
|
|
36
36
|
"generate": "nuxt generate .playground",
|
|
37
37
|
"preview": "nuxt preview .playground",
|
|
38
|
-
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && node --test \"test/*.test.mjs\" && yarn test:units",
|
|
38
|
+
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
|
|
39
39
|
"test:units": "node --experimental-strip-types --test \"utils/*.test.ts\"",
|
|
40
40
|
"release": "yarn run build && changeset publish"
|
|
41
41
|
},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-container fluid>
|
|
3
|
+
<HidEnabledGate
|
|
4
|
+
:site="siteId"
|
|
5
|
+
:org="orgId"
|
|
6
|
+
message="Enable HID as a service for this site before mapping HID users."
|
|
7
|
+
>
|
|
8
|
+
<HidUserMapping :site="siteId" />
|
|
9
|
+
</HidEnabledGate>
|
|
10
|
+
</v-container>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
definePageMeta({
|
|
15
|
+
layout: "default",
|
|
16
|
+
middleware: ["01-auth", "02-org"],
|
|
17
|
+
memberOnly: true,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const route = useRoute();
|
|
21
|
+
const siteId = computed(() => String(route.params.site ?? ""));
|
|
22
|
+
const orgId = computed(() => String(route.params.org ?? ""));
|
|
23
|
+
</script>
|
|
@@ -143,3 +143,98 @@ test("a billing cycle the screen does not know is shown, not swallowed", () => {
|
|
|
143
143
|
"Yearly",
|
|
144
144
|
);
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
/* ── SUSPEND / REACTIVATE, THE STATE THE LIST HAS TO SHOW AFTERWARDS ──────
|
|
148
|
+
*
|
|
149
|
+
* `PATCH /api/organizations/:id/status` (core `organization.controller.ts`
|
|
150
|
+
* `updateStatus`) writes BOTH `organizations.status` and the organisation's
|
|
151
|
+
* subscription status - the second one so the hourly sync job agrees rather
|
|
152
|
+
* than undoing the decision an hour later. So after the list is re-read, a
|
|
153
|
+
* suspended client arrives with both set, and this is what the row then says.
|
|
154
|
+
* Nothing in the screen holds a second copy of that state.
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/** Exactly what `updateStatus` leaves behind, applied to a list row. */
|
|
158
|
+
function afterStatusChange(org: Record<string, any>, status: "active" | "suspended") {
|
|
159
|
+
return {
|
|
160
|
+
...org,
|
|
161
|
+
status,
|
|
162
|
+
subscription: org.subscription?._id
|
|
163
|
+
? { ...org.subscription, status }
|
|
164
|
+
: org.subscription,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
test("suspending a client makes the row say Suspended", () => {
|
|
169
|
+
const before = orgWithSub({});
|
|
170
|
+
assert.equal(describeClientSubscription(before, NOW).state, "active");
|
|
171
|
+
|
|
172
|
+
const after = afterStatusChange(before, "suspended");
|
|
173
|
+
const v = describeClientSubscription(after, NOW);
|
|
174
|
+
|
|
175
|
+
assert.equal(v.state, "suspended");
|
|
176
|
+
assert.equal(v.label, "Suspended");
|
|
177
|
+
// Suspension is a decision somebody took, not a thing needing attention.
|
|
178
|
+
assert.equal(v.needsAttention, false);
|
|
179
|
+
// The dates are untouched - "data is kept" is visible, not just claimed.
|
|
180
|
+
assert.equal(v.billingCycle, describeClientSubscription(before, NOW).billingCycle);
|
|
181
|
+
assert.equal(v.start, describeClientSubscription(before, NOW).start);
|
|
182
|
+
assert.equal(v.end, describeClientSubscription(before, NOW).end);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("reactivating puts the row back exactly where it was", () => {
|
|
186
|
+
const before = orgWithSub({});
|
|
187
|
+
const round = afterStatusChange(afterStatusChange(before, "suspended"), "active");
|
|
188
|
+
|
|
189
|
+
assert.deepEqual(
|
|
190
|
+
describeClientSubscription(round, NOW),
|
|
191
|
+
describeClientSubscription(before, NOW),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a complimentary client suspends and reactivates the same way", () => {
|
|
196
|
+
// All 11 production clients are complimentary, so this is the case that
|
|
197
|
+
// actually happens - and `billingMode` must survive the round trip.
|
|
198
|
+
const before = orgWithSub({ billingMode: "complimentary" });
|
|
199
|
+
assert.equal(describeClientSubscription(before, NOW).state, "complimentary");
|
|
200
|
+
|
|
201
|
+
assert.equal(
|
|
202
|
+
describeClientSubscription(afterStatusChange(before, "suspended"), NOW).state,
|
|
203
|
+
"suspended",
|
|
204
|
+
);
|
|
205
|
+
assert.equal(
|
|
206
|
+
describeClientSubscription(afterStatusChange(before, "active"), NOW).state,
|
|
207
|
+
"complimentary",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a client with NO subscription document still suspends", () => {
|
|
212
|
+
// `updateStatus` writes the organisation's status either way and only
|
|
213
|
+
// touches a subscription if one exists. This row has none, so the
|
|
214
|
+
// subscription column keeps saying so - the ORGANISATION's status is what
|
|
215
|
+
// moved it to the Suspended tab, and that is the honest reading of the
|
|
216
|
+
// record. The list is fetched per tab, so the row is on the tab that matches.
|
|
217
|
+
const before = { _id: "o-1", name: "A Client", status: "active", subscription: {} };
|
|
218
|
+
const after = afterStatusChange(before, "suspended");
|
|
219
|
+
|
|
220
|
+
assert.equal(after.status, "suspended");
|
|
221
|
+
assert.equal(describeClientSubscription(after, NOW).state, "none");
|
|
222
|
+
assert.equal(describeClientSubscription(after, NOW).label, "No subscription set up");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("suspending does not clear an end date that had already passed", () => {
|
|
226
|
+
// Owner decision 8's flag and a suspension are different things, and the
|
|
227
|
+
// flag is derived at read time - suspending must not hide the fact that the
|
|
228
|
+
// subscription had run out, because reactivating brings it straight back.
|
|
229
|
+
const overdue = orgWithSub({ nextBillingDate: "2026-01-01T00:00:00.000Z" });
|
|
230
|
+
assert.equal(describeClientSubscription(overdue, NOW).needsAttention, true);
|
|
231
|
+
|
|
232
|
+
assert.equal(
|
|
233
|
+
describeClientSubscription(afterStatusChange(overdue, "suspended"), NOW).state,
|
|
234
|
+
"suspended",
|
|
235
|
+
);
|
|
236
|
+
assert.equal(
|
|
237
|
+
describeClientSubscription(afterStatusChange(overdue, "active"), NOW).needsAttention,
|
|
238
|
+
true,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { consoleTier } from "./console-tier.ts";
|
|
5
|
+
|
|
6
|
+
/** The seeded platform-staff role - `user.service.ts createDefaultUser()`. */
|
|
7
|
+
const OWNER_ROLE = {
|
|
8
|
+
_id: "r-owner",
|
|
9
|
+
name: "Super Admin",
|
|
10
|
+
type: "admin",
|
|
11
|
+
default: true,
|
|
12
|
+
permissions: [],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A role made through the admin app. `default` is not in its Joi schema. */
|
|
16
|
+
const STAFF_ROLE = {
|
|
17
|
+
_id: "r-staff",
|
|
18
|
+
name: "Operations",
|
|
19
|
+
type: "admin",
|
|
20
|
+
permissions: ["organization:read"],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ADMIN_MEMBER = { _id: "m-1", user: "u-1", type: "admin", role: "r-owner" };
|
|
24
|
+
|
|
25
|
+
test("the owner is an admin member on an admin role marked default", () => {
|
|
26
|
+
assert.equal(consoleTier(ADMIN_MEMBER, OWNER_ROLE), "owner");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("ordinary Seven365 staff are admin, but their role is not the default one", () => {
|
|
30
|
+
assert.equal(consoleTier(ADMIN_MEMBER, STAFF_ROLE), "staff");
|
|
31
|
+
// The absence of the field, not just `false`, is the ordinary case: the admin
|
|
32
|
+
// app's create-role form cannot send it at all.
|
|
33
|
+
assert.equal(consoleTier(ADMIN_MEMBER, { ...STAFF_ROLE, default: false }), "staff");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("BOTH halves are required - a role name proves nothing", () => {
|
|
37
|
+
// Staging carries an ordinary ORGANISATION role merely NAMED "Super Admin",
|
|
38
|
+
// and `web-app-org/pages/index.vue` gated the whole console on that name.
|
|
39
|
+
// That row must not reach owner, or staff, on the strength of its name.
|
|
40
|
+
const impostor = { _id: "r-x", name: "Super Admin", type: "organization", default: true };
|
|
41
|
+
assert.equal(consoleTier(ADMIN_MEMBER, impostor), "none");
|
|
42
|
+
|
|
43
|
+
// ...and an admin-typed role held through a non-admin membership is not
|
|
44
|
+
// staff either. `isSuperAdmin` requires `members.type === "admin"` too.
|
|
45
|
+
assert.equal(consoleTier({ ...ADMIN_MEMBER, type: "organization" }, OWNER_ROLE), "none");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("`default` is only honoured when it is exactly true", () => {
|
|
49
|
+
// A truthy-but-not-true value is not what `createDefaultUser` writes, and the
|
|
50
|
+
// server compares with `===`. Drawing an owner control off `"true"` or `1`
|
|
51
|
+
// would show a button the server then refuses.
|
|
52
|
+
for (const value of ["true", 1, {}, "yes"]) {
|
|
53
|
+
assert.equal(
|
|
54
|
+
consoleTier(ADMIN_MEMBER, { ...STAFF_ROLE, default: value }),
|
|
55
|
+
"staff",
|
|
56
|
+
JSON.stringify(value),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("anything unproven is `none` - this fails closed", () => {
|
|
62
|
+
const unproven: Array<[any, any]> = [
|
|
63
|
+
[null, OWNER_ROLE],
|
|
64
|
+
[undefined, OWNER_ROLE],
|
|
65
|
+
[{}, OWNER_ROLE],
|
|
66
|
+
[ADMIN_MEMBER, null], // the role request failed
|
|
67
|
+
[ADMIN_MEMBER, undefined],
|
|
68
|
+
[ADMIN_MEMBER, {}],
|
|
69
|
+
[null, null],
|
|
70
|
+
// An error body answered instead of a record. `member.controller.ts`
|
|
71
|
+
// answers `NotFoundError` as JSON, so this is a real wire shape.
|
|
72
|
+
[{ status: "error", message: "Member not found." }, OWNER_ROLE],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (const [member, role] of unproven) {
|
|
76
|
+
assert.equal(consoleTier(member, role), "none", JSON.stringify([member, role]));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a deleted staff membership is not staff", () => {
|
|
81
|
+
// `isSuperAdmin` excludes it server-side; the endpoint the browser reads does
|
|
82
|
+
// not, so it is excluded here to keep the two answers the same.
|
|
83
|
+
assert.equal(
|
|
84
|
+
consoleTier({ ...ADMIN_MEMBER, status: "deleted" }, OWNER_ROLE),
|
|
85
|
+
"none",
|
|
86
|
+
);
|
|
87
|
+
});
|