@7365admin1/layer-common 4.0.0 → 4.0.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/CHANGELOG.md +124 -0
- 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/HidAccessLogDashboard.vue +114 -34
- package/components/HidQrCodeConfiguration.vue +1 -1
- package/components/HidReaderManagement.vue +34 -11
- package/components/HidReaderUserRoster.vue +376 -0
- package/components/HidUserEnrollment.vue +7 -1
- package/components/HidUserMapping.vue +583 -0
- package/components/MemberMain.vue +12 -2
- package/components/RolePermissionMain.vue +2 -1
- package/composables/useConsoleTier.ts +73 -0
- package/composables/useHidAmico.ts +30 -6
- package/composables/useHidNavigation.ts +8 -1
- package/composables/useMember.ts +0 -5
- package/composables/usePromoCode.ts +57 -0
- package/package.json +1 -1
- 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
- package/utils/promo-code-form.test.ts +437 -0
- package/utils/promo-code-form.ts +246 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="hid-user-mapping">
|
|
3
|
+
<v-alert type="info" variant="tonal" density="compact" class="mb-4">
|
|
4
|
+
This screen links an existing Amico user to an iService record. It does not create,
|
|
5
|
+
rename, or delete the user on the HID reader. Visitor QR passes are linked from the
|
|
6
|
+
visitor transaction automatically.
|
|
7
|
+
</v-alert>
|
|
8
|
+
|
|
9
|
+
<TableMain
|
|
10
|
+
title="HID User Mapping"
|
|
11
|
+
:headers="headers"
|
|
12
|
+
:items="users"
|
|
13
|
+
:loading="loading"
|
|
14
|
+
:items-per-page="-1"
|
|
15
|
+
:page="page"
|
|
16
|
+
:pages="pages"
|
|
17
|
+
:page-range="pageRange"
|
|
18
|
+
item-value="_rowKey"
|
|
19
|
+
show-header
|
|
20
|
+
@refresh="loadUsers"
|
|
21
|
+
@update:page="goToPage"
|
|
22
|
+
>
|
|
23
|
+
<template #extension>
|
|
24
|
+
<div class="app-filter-row hid-user-mapping__filters">
|
|
25
|
+
<AppSelect
|
|
26
|
+
v-if="readers.length"
|
|
27
|
+
v-model="selectedReaderId"
|
|
28
|
+
class="reader-select"
|
|
29
|
+
:items="readerOptions"
|
|
30
|
+
@update:model-value="reloadFromFirstPage"
|
|
31
|
+
/>
|
|
32
|
+
<AppField
|
|
33
|
+
v-model="search"
|
|
34
|
+
search
|
|
35
|
+
placeholder="Search Amico user, UID or registration"
|
|
36
|
+
@keyup.enter="reloadFromFirstPage"
|
|
37
|
+
/>
|
|
38
|
+
<AppSelect
|
|
39
|
+
v-model="mappingFilter"
|
|
40
|
+
:items="mappingFilterOptions"
|
|
41
|
+
placeholder="Mapping status"
|
|
42
|
+
@update:model-value="reloadFromFirstPage"
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<template #[`item.name`]="{ item }">
|
|
48
|
+
<span class="app-cell--strong">{{ getRawName(item) }}</span>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<template #[`item.hidUserId`]="{ item }">
|
|
52
|
+
<span class="app-cell--num">{{ formatUid(item.hidUserId) }}</span>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<template #[`item.registration`]="{ item }">
|
|
56
|
+
<span class="app-cell--num">{{ item.registration || "N/A" }}</span>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<template #[`item.linkedRecord`]="{ item }">
|
|
60
|
+
<span :class="isMapped(item) ? 'app-cell--strong' : 'app-cell--muted'">
|
|
61
|
+
{{ getMappingLabel(item) }}
|
|
62
|
+
</span>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<template #[`item.mappingStatus`]="{ item }">
|
|
66
|
+
<StatusChip :status="isMapped(item) ? 'Mapped' : 'Unmapped'" />
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<template #[`item.action-table`]="{ item }">
|
|
70
|
+
<v-menu v-if="isMapped(item)">
|
|
71
|
+
<template #activator="{ props: menuProps }">
|
|
72
|
+
<AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
|
|
73
|
+
</template>
|
|
74
|
+
<v-list density="compact" min-width="150">
|
|
75
|
+
<v-list-item title="Change mapping" @click="openMapping(item)" />
|
|
76
|
+
<v-list-item title="Remove mapping" class="text-error" @click="openUnmap(item)" />
|
|
77
|
+
</v-list>
|
|
78
|
+
</v-menu>
|
|
79
|
+
<AppButton v-else variant="secondary" icon="mdi-link-variant" @click="openMapping(item)">
|
|
80
|
+
Map
|
|
81
|
+
</AppButton>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<template #no-data>
|
|
85
|
+
<div class="table-card__empty">
|
|
86
|
+
<v-icon icon="mdi-account-off-outline" size="32" />
|
|
87
|
+
<span>No HID users found for this mapping filter.</span>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
90
|
+
</TableMain>
|
|
91
|
+
|
|
92
|
+
<v-dialog v-model="mappingDialog" max-width="520" persistent>
|
|
93
|
+
<v-card rounded="lg">
|
|
94
|
+
<v-card-title class="small-title">
|
|
95
|
+
{{ isMapped(selectedUser) ? "Change HID User Mapping" : "Map HID User" }}
|
|
96
|
+
</v-card-title>
|
|
97
|
+
<v-card-text>
|
|
98
|
+
<div class="hid-user-summary">
|
|
99
|
+
<strong>{{ getRawName(selectedUser) }}</strong>
|
|
100
|
+
<span>{{ formatUid(selectedUser?.hidUserId) }} · {{ selectedUser?.registration || "No registration" }}</span>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div class="field-label">Link To <span>*</span></div>
|
|
104
|
+
<AppSelect
|
|
105
|
+
v-model="mappingForm.category"
|
|
106
|
+
:items="categoryOptions"
|
|
107
|
+
placeholder="Select record type"
|
|
108
|
+
class="mb-3"
|
|
109
|
+
@update:model-value="onCategoryChanged"
|
|
110
|
+
/>
|
|
111
|
+
|
|
112
|
+
<div class="field-label">iService Record <span>*</span></div>
|
|
113
|
+
<AppSelect
|
|
114
|
+
v-model="mappingForm.subjectId"
|
|
115
|
+
:items="candidateOptions"
|
|
116
|
+
:loading="loadingCandidates"
|
|
117
|
+
placeholder="Select an existing site record"
|
|
118
|
+
/>
|
|
119
|
+
<p class="field-hint">
|
|
120
|
+
{{ categoryHint }}
|
|
121
|
+
</p>
|
|
122
|
+
</v-card-text>
|
|
123
|
+
<v-card-actions class="pa-0">
|
|
124
|
+
<v-btn class="text-none action-cancel" variant="flat" height="44" @click="mappingDialog = false">
|
|
125
|
+
Cancel
|
|
126
|
+
</v-btn>
|
|
127
|
+
<v-btn
|
|
128
|
+
class="text-none action-submit"
|
|
129
|
+
variant="flat"
|
|
130
|
+
height="44"
|
|
131
|
+
:loading="saving"
|
|
132
|
+
@click="saveMapping"
|
|
133
|
+
>
|
|
134
|
+
Save Mapping
|
|
135
|
+
</v-btn>
|
|
136
|
+
</v-card-actions>
|
|
137
|
+
</v-card>
|
|
138
|
+
</v-dialog>
|
|
139
|
+
|
|
140
|
+
<v-dialog v-model="unmapDialog" max-width="420" persistent>
|
|
141
|
+
<v-card rounded="lg">
|
|
142
|
+
<v-card-title class="small-title">Remove HID User Mapping</v-card-title>
|
|
143
|
+
<v-card-text>
|
|
144
|
+
Remove the iService link for <strong>{{ getRawName(selectedUser) }}</strong>?
|
|
145
|
+
The user and their credentials remain on the HID reader.
|
|
146
|
+
</v-card-text>
|
|
147
|
+
<v-card-actions>
|
|
148
|
+
<v-spacer />
|
|
149
|
+
<AppButton variant="ghost" :disabled="saving" @click="unmapDialog = false">Cancel</AppButton>
|
|
150
|
+
<AppButton :loading="saving" @click="removeMapping">Remove Mapping</AppButton>
|
|
151
|
+
</v-card-actions>
|
|
152
|
+
</v-card>
|
|
153
|
+
</v-dialog>
|
|
154
|
+
|
|
155
|
+
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3500">
|
|
156
|
+
{{ snackbar.message }}
|
|
157
|
+
</v-snackbar>
|
|
158
|
+
</section>
|
|
159
|
+
</template>
|
|
160
|
+
|
|
161
|
+
<script setup lang="ts">
|
|
162
|
+
const props = defineProps({
|
|
163
|
+
site: {
|
|
164
|
+
type: String,
|
|
165
|
+
required: true,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
type HidMetadata = Record<string, unknown> & {
|
|
170
|
+
name?: string;
|
|
171
|
+
mappingLabel?: string;
|
|
172
|
+
mappingCategory?: THidPermissionCategory;
|
|
173
|
+
};
|
|
174
|
+
type HidReader = Record<string, unknown> & {
|
|
175
|
+
_id: string;
|
|
176
|
+
name?: string;
|
|
177
|
+
deviceId?: string;
|
|
178
|
+
portalName?: string;
|
|
179
|
+
};
|
|
180
|
+
type HidReaderUser = Record<string, unknown> & {
|
|
181
|
+
_id?: string;
|
|
182
|
+
_rowKey?: string;
|
|
183
|
+
hidUserId?: string;
|
|
184
|
+
registration?: string;
|
|
185
|
+
name?: string;
|
|
186
|
+
metadata?: HidMetadata;
|
|
187
|
+
person?: string;
|
|
188
|
+
member?: string;
|
|
189
|
+
serviceProvider?: string;
|
|
190
|
+
visitor?: string;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const { getReaders, getReaderUsers, getPermissionCandidates, createIdentity, updateIdentity, deleteIdentity } = useHidAmico();
|
|
194
|
+
const route = useRoute();
|
|
195
|
+
const orgId = computed(() => String(route.params.org || ""));
|
|
196
|
+
const readers = ref<HidReader[]>([]);
|
|
197
|
+
const users = ref<HidReaderUser[]>([]);
|
|
198
|
+
const selectedReaderId = ref("");
|
|
199
|
+
const search = ref("");
|
|
200
|
+
const mappingFilter = ref<"" | "mapped" | "unmapped">("");
|
|
201
|
+
const page = ref(1);
|
|
202
|
+
const pages = ref(1);
|
|
203
|
+
const total = ref(0);
|
|
204
|
+
const serverPageRange = ref("");
|
|
205
|
+
const loading = ref(false);
|
|
206
|
+
const saving = ref(false);
|
|
207
|
+
const mappingDialog = ref(false);
|
|
208
|
+
const unmapDialog = ref(false);
|
|
209
|
+
const selectedUser = ref<HidReaderUser | null>(null);
|
|
210
|
+
const permissionCandidates = ref<THidPermissionCandidate[]>([]);
|
|
211
|
+
const loadingCandidates = ref(false);
|
|
212
|
+
const snackbar = reactive({ show: false, color: "success", message: "" });
|
|
213
|
+
const mappingForm = reactive({
|
|
214
|
+
category: "resident" as THidPermissionCategory,
|
|
215
|
+
subjectId: "",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const headers = [
|
|
219
|
+
{ title: "Amico Name", value: "name", sortable: false },
|
|
220
|
+
{ title: "UID", value: "hidUserId", sortable: false },
|
|
221
|
+
{ title: "Registration No.", value: "registration", sortable: false },
|
|
222
|
+
{ title: "iService Record", value: "linkedRecord", sortable: false },
|
|
223
|
+
{ title: "Status", value: "mappingStatus", sortable: false },
|
|
224
|
+
{ title: "", value: "action-table", sortable: false },
|
|
225
|
+
];
|
|
226
|
+
const mappingFilterOptions = [
|
|
227
|
+
{ title: "All users", value: "" },
|
|
228
|
+
{ title: "Unmapped", value: "unmapped" },
|
|
229
|
+
{ title: "Mapped", value: "mapped" },
|
|
230
|
+
];
|
|
231
|
+
const categoryOptions = [
|
|
232
|
+
{ title: "Resident", value: "resident" },
|
|
233
|
+
{ title: "Property management", value: "property_management" },
|
|
234
|
+
{ title: "Service provider", value: "service_provider" },
|
|
235
|
+
];
|
|
236
|
+
const readerOptions = computed(() => readers.value.map((reader) => ({
|
|
237
|
+
title: `${reader.name || reader.deviceId || reader._id} - ${reader.portalName || "Portal not configured"}`,
|
|
238
|
+
value: reader._id,
|
|
239
|
+
})));
|
|
240
|
+
const candidateOptions = computed(() => permissionCandidates.value.map((candidate) => ({
|
|
241
|
+
title: candidate.subtitle ? `${candidate.name} · ${candidate.subtitle}` : candidate.name,
|
|
242
|
+
value: candidate.subjectId,
|
|
243
|
+
})));
|
|
244
|
+
const categoryHint = computed(() => {
|
|
245
|
+
if (mappingForm.category === "service_provider") {
|
|
246
|
+
return "Service provider candidates are provider records assigned to this site.";
|
|
247
|
+
}
|
|
248
|
+
return "The HID identity is linked to one active record at this site.";
|
|
249
|
+
});
|
|
250
|
+
const pageRange = computed(() => {
|
|
251
|
+
if (serverPageRange.value) return serverPageRange.value;
|
|
252
|
+
if (!total.value) return "0-0 of 0";
|
|
253
|
+
const start = (page.value - 1) * 10 + 1;
|
|
254
|
+
return `${start}-${Math.min(page.value * 10, total.value)} of ${total.value}`;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
watch(() => props.site, init, { immediate: true });
|
|
258
|
+
|
|
259
|
+
async function init() {
|
|
260
|
+
await loadReaders();
|
|
261
|
+
await loadUsers();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function loadReaders() {
|
|
265
|
+
if (!props.site) return;
|
|
266
|
+
try {
|
|
267
|
+
const response = toRecord(await getReaders({ site: props.site, page: 1, limit: 100 }));
|
|
268
|
+
const data = toRecord(response.data);
|
|
269
|
+
const items = response.items ?? data.items ?? data.readers ?? [];
|
|
270
|
+
readers.value = (Array.isArray(items) ? items : [])
|
|
271
|
+
.map(toReader)
|
|
272
|
+
.filter((reader): reader is HidReader => reader !== null);
|
|
273
|
+
if (!selectedReaderId.value && readers.value.length) selectedReaderId.value = readers.value[0]._id;
|
|
274
|
+
} catch (error: unknown) {
|
|
275
|
+
readers.value = [];
|
|
276
|
+
showToast(getErrorMessage(error), "error");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function loadUsers() {
|
|
281
|
+
if (!selectedReaderId.value) {
|
|
282
|
+
users.value = [];
|
|
283
|
+
total.value = 0;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
loading.value = true;
|
|
288
|
+
try {
|
|
289
|
+
const response = toRecord(await getReaderUsers(selectedReaderId.value, {
|
|
290
|
+
page: page.value,
|
|
291
|
+
limit: 10,
|
|
292
|
+
search: search.value.trim(),
|
|
293
|
+
status: mappingFilter.value,
|
|
294
|
+
}));
|
|
295
|
+
const data = toRecord(response.data);
|
|
296
|
+
const items = response.items ?? data.items ?? [];
|
|
297
|
+
users.value = (Array.isArray(items) ? items : []).map(toReaderUser);
|
|
298
|
+
total.value = toNumber(response.total ?? data.total, users.value.length);
|
|
299
|
+
pages.value = Math.max(1, toNumber(response.pages ?? data.pages, 1));
|
|
300
|
+
serverPageRange.value = toText(response.pageRange ?? data.pageRange);
|
|
301
|
+
} catch (error: unknown) {
|
|
302
|
+
users.value = [];
|
|
303
|
+
total.value = 0;
|
|
304
|
+
pages.value = 1;
|
|
305
|
+
serverPageRange.value = "";
|
|
306
|
+
showToast(getErrorMessage(error), "error");
|
|
307
|
+
} finally {
|
|
308
|
+
loading.value = false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function goToPage(nextPage: number) {
|
|
313
|
+
page.value = nextPage;
|
|
314
|
+
loadUsers();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function reloadFromFirstPage() {
|
|
318
|
+
page.value = 1;
|
|
319
|
+
loadUsers();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function openMapping(user: HidReaderUser) {
|
|
323
|
+
selectedUser.value = user;
|
|
324
|
+
mappingForm.category = getMappingCategory(user);
|
|
325
|
+
mappingForm.subjectId = getMappedSubjectId(user);
|
|
326
|
+
await loadCandidates();
|
|
327
|
+
mappingDialog.value = true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function onCategoryChanged() {
|
|
331
|
+
mappingForm.subjectId = "";
|
|
332
|
+
await loadCandidates();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function loadCandidates() {
|
|
336
|
+
if (!selectedReaderId.value || !orgId.value) {
|
|
337
|
+
permissionCandidates.value = [];
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
loadingCandidates.value = true;
|
|
341
|
+
try {
|
|
342
|
+
const response = await getPermissionCandidates(props.site, {
|
|
343
|
+
orgId: orgId.value,
|
|
344
|
+
readerId: selectedReaderId.value,
|
|
345
|
+
category: mappingForm.category,
|
|
346
|
+
page: 1,
|
|
347
|
+
limit: 500,
|
|
348
|
+
});
|
|
349
|
+
permissionCandidates.value = response.items ?? response.data?.items ?? [];
|
|
350
|
+
} catch (error: unknown) {
|
|
351
|
+
permissionCandidates.value = [];
|
|
352
|
+
showToast(getErrorMessage(error), "error");
|
|
353
|
+
} finally {
|
|
354
|
+
loadingCandidates.value = false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function saveMapping() {
|
|
359
|
+
const user = selectedUser.value;
|
|
360
|
+
const hidUserId = toText(user?.hidUserId);
|
|
361
|
+
const candidate = permissionCandidates.value.find((item) => item.subjectId === mappingForm.subjectId);
|
|
362
|
+
if (!user || !selectedReaderId.value || !hidUserId || !candidate) {
|
|
363
|
+
showToast("Select an iService record to create the HID user mapping.", "error");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
saving.value = true;
|
|
368
|
+
try {
|
|
369
|
+
const payload = {
|
|
370
|
+
site: props.site,
|
|
371
|
+
hidUserId,
|
|
372
|
+
registration: toText(user.registration),
|
|
373
|
+
person: mappingForm.category === "resident" ? candidate.subjectId : "",
|
|
374
|
+
member: mappingForm.category === "property_management" ? candidate.subjectId : "",
|
|
375
|
+
serviceProvider: mappingForm.category === "service_provider" ? candidate.subjectId : "",
|
|
376
|
+
type: getIdentityType(mappingForm.category),
|
|
377
|
+
status: "active" as const,
|
|
378
|
+
metadata: {
|
|
379
|
+
...toRecord(user.metadata),
|
|
380
|
+
name: getRawName(user),
|
|
381
|
+
mappingLabel: candidate.subtitle ? `${candidate.name} · ${candidate.subtitle}` : candidate.name,
|
|
382
|
+
mappingCategory: mappingForm.category,
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
const identityId = getIdentityId(user);
|
|
386
|
+
if (identityId) {
|
|
387
|
+
await updateIdentity(identityId, payload);
|
|
388
|
+
} else {
|
|
389
|
+
await createIdentity(selectedReaderId.value, payload);
|
|
390
|
+
}
|
|
391
|
+
mappingDialog.value = false;
|
|
392
|
+
await loadUsers();
|
|
393
|
+
showToast("HID user mapping saved.");
|
|
394
|
+
} catch (error: unknown) {
|
|
395
|
+
showToast(getErrorMessage(error), "error");
|
|
396
|
+
} finally {
|
|
397
|
+
saving.value = false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function openUnmap(user: HidReaderUser) {
|
|
402
|
+
selectedUser.value = user;
|
|
403
|
+
unmapDialog.value = true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function removeMapping() {
|
|
407
|
+
const identityId = selectedUser.value ? getIdentityId(selectedUser.value) : "";
|
|
408
|
+
if (!identityId) {
|
|
409
|
+
showToast("This HID user does not have a removable iService mapping.", "error");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
saving.value = true;
|
|
413
|
+
try {
|
|
414
|
+
await deleteIdentity(identityId);
|
|
415
|
+
unmapDialog.value = false;
|
|
416
|
+
await loadUsers();
|
|
417
|
+
showToast("HID user mapping removed. The reader user is unchanged.");
|
|
418
|
+
} catch (error: unknown) {
|
|
419
|
+
showToast(getErrorMessage(error), "error");
|
|
420
|
+
} finally {
|
|
421
|
+
saving.value = false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isMapped(user?: HidReaderUser | null) {
|
|
426
|
+
return Boolean(user?.person || user?.member || user?.serviceProvider || user?.visitor);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function getIdentityId(user: HidReaderUser) {
|
|
430
|
+
return isMapped(user) ? toText(user._id) : "";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function getMappingCategory(user: HidReaderUser): THidPermissionCategory {
|
|
434
|
+
if (user.serviceProvider) return "service_provider";
|
|
435
|
+
if (user.member) return "property_management";
|
|
436
|
+
return "resident";
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function getMappedSubjectId(user: HidReaderUser) {
|
|
440
|
+
return toText(user.person) || toText(user.member) || toText(user.serviceProvider);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function getMappingLabel(user: HidReaderUser) {
|
|
444
|
+
if (!isMapped(user)) return "Unmapped";
|
|
445
|
+
return toText(user.metadata?.mappingLabel) || `${getCategoryTitle(getMappingCategory(user))} record`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function getCategoryTitle(category: THidPermissionCategory) {
|
|
449
|
+
return category === "property_management"
|
|
450
|
+
? "Property management"
|
|
451
|
+
: category === "service_provider" ? "Service provider" : "Resident";
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function getIdentityType(category: THidPermissionCategory) {
|
|
455
|
+
return category === "resident"
|
|
456
|
+
? "resident" as const
|
|
457
|
+
: category === "service_provider" ? "contractor" as const : "staff" as const;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function getRawName(user?: HidReaderUser | null) {
|
|
461
|
+
return toText(user?.metadata?.name) || toText(user?.name) || "Unknown";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function formatUid(value: unknown) {
|
|
465
|
+
const text = toText(value);
|
|
466
|
+
if (!text) return "N/A";
|
|
467
|
+
const numeric = Number(text);
|
|
468
|
+
return Number.isSafeInteger(numeric) && numeric > 0 ? `UID${String(numeric).padStart(6, "0")}` : text;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function showToast(message: string, color = "success") {
|
|
472
|
+
snackbar.message = message;
|
|
473
|
+
snackbar.color = color;
|
|
474
|
+
snackbar.show = true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function getErrorMessage(error: unknown) {
|
|
478
|
+
const record = toRecord(error);
|
|
479
|
+
const data = toRecord(record.data);
|
|
480
|
+
return toText(data.message) || toText(record.message) || "Unable to update HID user mapping.";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function toReader(value: unknown): HidReader | null {
|
|
484
|
+
const reader = toRecord(value);
|
|
485
|
+
const id = toText(reader._id);
|
|
486
|
+
return id ? { ...reader, _id: id, name: toText(reader.name) || undefined } : null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function toReaderUser(value: unknown): HidReaderUser {
|
|
490
|
+
const user = toRecord(value);
|
|
491
|
+
return {
|
|
492
|
+
...user,
|
|
493
|
+
_id: toText(user._id) || undefined,
|
|
494
|
+
_rowKey: toText(user._rowKey) || toText(user.id) || toText(user.hidUserId),
|
|
495
|
+
hidUserId: toText(user.hidUserId) || toText(user.id) || undefined,
|
|
496
|
+
registration: toText(user.registration) || undefined,
|
|
497
|
+
name: toText(user.name) || undefined,
|
|
498
|
+
metadata: toMetadata(user.metadata),
|
|
499
|
+
person: toText(user.person) || undefined,
|
|
500
|
+
member: toText(user.member) || undefined,
|
|
501
|
+
serviceProvider: toText(user.serviceProvider) || undefined,
|
|
502
|
+
visitor: toText(user.visitor) || undefined,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function toMetadata(value: unknown): HidMetadata {
|
|
507
|
+
return toRecord(value) as HidMetadata;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function toRecord(value: unknown): Record<string, unknown> {
|
|
511
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
512
|
+
? value as Record<string, unknown>
|
|
513
|
+
: {};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function toText(value: unknown) {
|
|
517
|
+
if (typeof value === "string") return value.trim();
|
|
518
|
+
if (typeof value === "number") return String(value);
|
|
519
|
+
return "";
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function toNumber(value: unknown, fallback: number) {
|
|
523
|
+
const number = Number(value);
|
|
524
|
+
return Number.isFinite(number) ? number : fallback;
|
|
525
|
+
}
|
|
526
|
+
</script>
|
|
527
|
+
|
|
528
|
+
<style scoped>
|
|
529
|
+
.hid-user-mapping {
|
|
530
|
+
padding: 24px 0;
|
|
531
|
+
min-width: 0;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.hid-user-mapping__filters {
|
|
535
|
+
margin: 0;
|
|
536
|
+
padding: 10px 14px;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.hid-user-mapping__filters :deep(.app-field) {
|
|
540
|
+
max-width: 320px;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
.small-title,
|
|
544
|
+
.field-label,
|
|
545
|
+
.hid-user-summary strong {
|
|
546
|
+
color: var(--text);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.small-title {
|
|
550
|
+
font-size: 14px;
|
|
551
|
+
font-weight: 700;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.field-label {
|
|
555
|
+
font-size: 12px;
|
|
556
|
+
font-weight: 600;
|
|
557
|
+
margin-bottom: 6px;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
.field-label span {
|
|
561
|
+
color: var(--err);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
.field-hint {
|
|
565
|
+
margin: 6px 0 0;
|
|
566
|
+
color: var(--muted);
|
|
567
|
+
font-size: 12px;
|
|
568
|
+
font-weight: 600;
|
|
569
|
+
line-height: 1.45;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
.hid-user-summary {
|
|
573
|
+
display: grid;
|
|
574
|
+
gap: 4px;
|
|
575
|
+
margin-bottom: 18px;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.hid-user-summary span {
|
|
579
|
+
color: var(--muted);
|
|
580
|
+
font-size: 12px;
|
|
581
|
+
font-weight: 600;
|
|
582
|
+
}
|
|
583
|
+
</style>
|
|
@@ -491,7 +491,13 @@ async function updateMemberRole() {
|
|
|
491
491
|
await setMember({ mode: "assign-role" });
|
|
492
492
|
await getAll();
|
|
493
493
|
} catch (error: any) {
|
|
494
|
-
message.value
|
|
494
|
+
// `message.value` alone sets the snackbar's TEXT and never opens it, so a
|
|
495
|
+
// refused role change said nothing at all - the dialog just closed and the
|
|
496
|
+
// row kept its old role. `showMessage` is the one that shows it.
|
|
497
|
+
showMessage(
|
|
498
|
+
error?.response?._data?.message || "Failed to update role.",
|
|
499
|
+
"error",
|
|
500
|
+
);
|
|
495
501
|
}
|
|
496
502
|
}
|
|
497
503
|
|
|
@@ -543,7 +549,11 @@ async function handleUpdateMemberStatus() {
|
|
|
543
549
|
showMessage(res.message, "success");
|
|
544
550
|
getAll();
|
|
545
551
|
} catch (error: any) {
|
|
546
|
-
|
|
552
|
+
// An error with no `message` on it drew an EMPTY red snackbar - a failure
|
|
553
|
+
// reported as a blank bar.
|
|
554
|
+
const errorMessage =
|
|
555
|
+
error?.response?._data?.message ||
|
|
556
|
+
`Could not ${updateActionText.value.toLowerCase()} this member.`;
|
|
547
557
|
showMessage(errorMessage, "error");
|
|
548
558
|
} finally {
|
|
549
559
|
updateLoading.value = false;
|
|
@@ -348,7 +348,8 @@ const { getRoleById: _getRoleById, deleteRole } = useRole();
|
|
|
348
348
|
|
|
349
349
|
const { data: role, refresh: getRoleById } = useLazyAsyncData(
|
|
350
350
|
"role-permissions-get-by-id",
|
|
351
|
-
() => _getRoleById(roleId.value)
|
|
351
|
+
() => _getRoleById(roleId.value),
|
|
352
|
+
{ immediate: false }
|
|
352
353
|
);
|
|
353
354
|
|
|
354
355
|
watchEffect(() => {
|
|
@@ -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
|
+
}
|