@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/components/AccessCardQrTagging.vue +0 -4
  3. package/components/AccessManagement.vue +0 -4
  4. package/components/BuildingManagement/buildings.vue +0 -4
  5. package/components/BulletinBoardManagement.vue +0 -3
  6. package/components/CameraForm.vue +2 -5
  7. package/components/CameraMain.vue +3 -6
  8. package/components/ClientDetailForm.vue +65 -5
  9. package/components/ClientMain.vue +201 -59
  10. package/components/DashboardMain.vue +27 -8
  11. package/components/DocumentManagement.vue +0 -4
  12. package/components/HidAccessLogDashboard.vue +114 -34
  13. package/components/HidReaderManagement.vue +34 -11
  14. package/components/HidReaderUserRoster.vue +376 -0
  15. package/components/HidUserEnrollment.vue +6 -0
  16. package/components/HidUserMapping.vue +583 -0
  17. package/components/IncidentReport/Authorities.vue +0 -4
  18. package/components/IncidentReport/IncidentInformation.vue +0 -4
  19. package/components/IncidentReport/IncidentInformationDownload.vue +0 -4
  20. package/components/IncidentReport/affectedEntities.vue +0 -4
  21. package/components/MemberMain.vue +20 -2
  22. package/components/RolePermissionFormCreate.vue +6 -1
  23. package/components/RolePermissionFormPreviewUpdate.vue +22 -11
  24. package/components/RolePermissionMain.vue +39 -4
  25. package/components/VehicleManagement.vue +0 -4
  26. package/components/VisitorManagement.vue +0 -3
  27. package/components/VisitorsReportPreview.vue +0 -3
  28. package/composables/useBulletinBoardPermission.ts +6 -1
  29. package/composables/useConsoleTier.ts +73 -0
  30. package/composables/useHidAmico.ts +18 -1
  31. package/composables/useHidNavigation.ts +8 -1
  32. package/composables/useMember.ts +0 -5
  33. package/composables/useSettingsPermission.ts +7 -1
  34. package/package.json +2 -2
  35. package/pages/[org]/[site]/access-mgmt/hid-user-mapping/index.vue +23 -0
  36. package/pages/[org]/[site]/access-mgmt/hid-users/index.vue +1 -1
  37. package/utils/client-subscription.test.ts +95 -0
  38. package/utils/console-tier.test.ts +87 -0
  39. package/utils/console-tier.ts +67 -0
  40. package/utils/data.test.ts +84 -0
  41. package/utils/data.ts +92 -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>
@@ -213,10 +213,6 @@
213
213
  </style>
214
214
 
215
215
  <script lang="ts" setup>
216
- definePageMeta({
217
- middleware: ["01-auth", "02-org"],
218
- memberOnly: true,
219
- });
220
216
 
221
217
  // imports
222
218
 
@@ -249,10 +249,6 @@
249
249
  </template>
250
250
 
251
251
  <script lang="ts" setup>
252
- definePageMeta({
253
- middleware: ["01-auth", "02-org"],
254
- memberOnly: true,
255
- });
256
252
 
257
253
  // props
258
254
  const props = defineProps({
@@ -155,10 +155,6 @@
155
155
  </template>
156
156
 
157
157
  <script lang="ts" setup>
158
- definePageMeta({
159
- middleware: ["01-auth", "02-org"],
160
- memberOnly: true,
161
- });
162
158
 
163
159
  // props
164
160
  const props = defineProps({
@@ -97,10 +97,6 @@
97
97
  </style>
98
98
 
99
99
  <script lang="ts" setup>
100
- definePageMeta({
101
- middleware: ["01-auth", "02-org"],
102
- memberOnly: true,
103
- });
104
100
 
105
101
  // imports
106
102
 
@@ -84,8 +84,16 @@
84
84
  {{ formatDateInvited(item.dateInvited) }}
85
85
  </template>
86
86
  <template #item.action-table="{ item }">
87
+ <!--
88
+ `props.assignRole` belongs in this test. The menu's only item that
89
+ is not status-dependent is Assign Role, but the menu itself opened
90
+ only for suspend / activate / delete -- so a role granted
91
+ `members:assign-member-role` and nothing else got no menu at all,
92
+ and the permission was unreachable.
93
+ -->
87
94
  <v-menu
88
95
  v-if="
96
+ props.assignRole ||
89
97
  (currentStatus === 'active' &&
90
98
  (canSuspendMembers || canDeleteMembers)) ||
91
99
  (currentStatus === 'suspended' &&
@@ -491,7 +499,13 @@ async function updateMemberRole() {
491
499
  await setMember({ mode: "assign-role" });
492
500
  await getAll();
493
501
  } catch (error: any) {
494
- message.value = error?.response?._data?.message || "Failed to update role";
502
+ // `message.value` alone sets the snackbar's TEXT and never opens it, so a
503
+ // refused role change said nothing at all - the dialog just closed and the
504
+ // row kept its old role. `showMessage` is the one that shows it.
505
+ showMessage(
506
+ error?.response?._data?.message || "Failed to update role.",
507
+ "error",
508
+ );
495
509
  }
496
510
  }
497
511
 
@@ -543,7 +557,11 @@ async function handleUpdateMemberStatus() {
543
557
  showMessage(res.message, "success");
544
558
  getAll();
545
559
  } catch (error: any) {
546
- const errorMessage = error?.response?._data?.message;
560
+ // An error with no `message` on it drew an EMPTY red snackbar - a failure
561
+ // reported as a blank bar.
562
+ const errorMessage =
563
+ error?.response?._data?.message ||
564
+ `Could not ${updateActionText.value.toLowerCase()} this member.`;
547
565
  showMessage(errorMessage, "error");
548
566
  } finally {
549
567
  updateLoading.value = false;
@@ -317,7 +317,12 @@ async function submit() {
317
317
  });
318
318
  emit("success");
319
319
  } catch (error: any) {
320
- message.value = error.response._data.message;
320
+ // Same unguarded chain as the preview dialog had: on a network or timeout
321
+ // failure `error.response` is undefined and the catch threw inside itself,
322
+ // leaving the user with no message at all.
323
+ message.value =
324
+ error?.response?._data?.message ??
325
+ "Could not create this role. Please check your connection and try again.";
321
326
  } finally {
322
327
  disable.value = false;
323
328
  }