@7365admin1/layer-common 3.2.8-staging.211 → 3.2.8-staging.212
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/ClientDetailForm.vue +65 -5
- package/components/ClientMain.vue +201 -59
- package/components/MemberMain.vue +12 -2
- package/composables/useConsoleTier.ts +73 -0
- package/package.json +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
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
|
|
10
10
|
<div class="actions">
|
|
11
11
|
<button class="btn-cancel" @click="$emit('cancel')">Cancel</button>
|
|
12
|
-
<button class="btn-submit" @click="handleSubmit">
|
|
12
|
+
<button class="btn-submit" :disabled="saving" @click="handleSubmit">
|
|
13
|
+
{{ saving ? 'Saving...' : 'Submit' }}
|
|
14
|
+
</button>
|
|
13
15
|
</div>
|
|
14
16
|
</div>
|
|
15
17
|
|
|
18
|
+
<p v-if="loadError" class="form-error" role="alert">{{ loadError }}</p>
|
|
19
|
+
<p v-else-if="formError" class="form-error" role="alert">{{ formError }}</p>
|
|
20
|
+
|
|
16
21
|
<!-- ===== CLIENT INFORMATION ===== -->
|
|
17
22
|
<section class="section">
|
|
18
23
|
<h3>Client Information</h3>
|
|
@@ -133,7 +138,12 @@
|
|
|
133
138
|
<!-- ===== INCLUDED APPLICATION ===== -->
|
|
134
139
|
<section class="section">
|
|
135
140
|
<h3>Included Application</h3>
|
|
136
|
-
|
|
141
|
+
<!-- A plain organisation has no service application of its own, so the
|
|
142
|
+
list is legitimately empty. An empty <ul> reads as a broken box. -->
|
|
143
|
+
<p v-if="form.includedApps.length === 0" class="app-none">
|
|
144
|
+
No service application recorded for this client.
|
|
145
|
+
</p>
|
|
146
|
+
<ul v-else class="app-list">
|
|
137
147
|
<li v-for="(app, idx) in form.includedApps" :key="idx">{{ app }}</li>
|
|
138
148
|
</ul>
|
|
139
149
|
</section>
|
|
@@ -266,6 +276,10 @@ const errors = reactive({
|
|
|
266
276
|
const phonePrefix = ref('+65')
|
|
267
277
|
const phoneNumber = ref('')
|
|
268
278
|
|
|
279
|
+
const saving = ref(false)
|
|
280
|
+
const loadError = ref('')
|
|
281
|
+
const formError = ref('')
|
|
282
|
+
|
|
269
283
|
/* ── FORM STATE ── */
|
|
270
284
|
const form = reactive({
|
|
271
285
|
name: '',
|
|
@@ -348,6 +362,9 @@ async function handleSubmit() {
|
|
|
348
362
|
|
|
349
363
|
form.phone = `${phonePrefix.value} ${phoneNumber.value}`
|
|
350
364
|
|
|
365
|
+
saving.value = true
|
|
366
|
+
formError.value = ''
|
|
367
|
+
|
|
351
368
|
try {
|
|
352
369
|
if (userId.value) {
|
|
353
370
|
await userApi.updateUserFieldById(userId.value, 'name', form.name)
|
|
@@ -359,8 +376,16 @@ async function handleSubmit() {
|
|
|
359
376
|
})
|
|
360
377
|
|
|
361
378
|
emit('submit', { ...form })
|
|
362
|
-
} catch (error) {
|
|
363
|
-
console.error
|
|
379
|
+
} catch (error: any) {
|
|
380
|
+
// A failed save used to reach `console.error` and nothing else, so Submit
|
|
381
|
+
// looked exactly like a button that did nothing - the screen stayed put
|
|
382
|
+
// with the typed values still in it, which reads as "not saved yet".
|
|
383
|
+
formError.value =
|
|
384
|
+
error?.response?._data?.message ??
|
|
385
|
+
error?.data?.message ??
|
|
386
|
+
'Could not save this client. Nothing was changed.'
|
|
387
|
+
} finally {
|
|
388
|
+
saving.value = false
|
|
364
389
|
}
|
|
365
390
|
}
|
|
366
391
|
const userId = ref('')
|
|
@@ -419,7 +444,19 @@ watch(
|
|
|
419
444
|
async client => {
|
|
420
445
|
if (!client?._id) return
|
|
421
446
|
|
|
422
|
-
|
|
447
|
+
loadError.value = ''
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
await Promise.all([loadOrganization(), loadSites()])
|
|
451
|
+
} catch (error: any) {
|
|
452
|
+
// Neither loader caught anything, so a failed request rejected inside
|
|
453
|
+
// this watcher and the drawer drew every field blank and an empty site
|
|
454
|
+
// table - an error shown as "this client has nothing in it".
|
|
455
|
+
loadError.value =
|
|
456
|
+
error?.response?._data?.message ??
|
|
457
|
+
error?.data?.message ??
|
|
458
|
+
'Could not load this client. Nothing here has been saved or changed.'
|
|
459
|
+
}
|
|
423
460
|
},
|
|
424
461
|
{
|
|
425
462
|
immediate: true
|
|
@@ -654,6 +691,29 @@ watch(
|
|
|
654
691
|
color: var(--err);
|
|
655
692
|
}
|
|
656
693
|
|
|
694
|
+
/* A failed load or a failed save, said once at the top of the form where the
|
|
695
|
+
reader already is. `--err-bg` fill + `--err` ink, the design's error pair. */
|
|
696
|
+
.form-error {
|
|
697
|
+
margin: 0 28px 18px;
|
|
698
|
+
padding: 10px 12px;
|
|
699
|
+
border-radius: 8px;
|
|
700
|
+
background: var(--err-bg);
|
|
701
|
+
color: var(--err);
|
|
702
|
+
font-size: 13px;
|
|
703
|
+
font-weight: 500;
|
|
704
|
+
line-height: 1.45;
|
|
705
|
+
}
|
|
706
|
+
.btn-submit:disabled {
|
|
707
|
+
opacity: 0.7;
|
|
708
|
+
cursor: not-allowed;
|
|
709
|
+
}
|
|
710
|
+
/* `--text2` is the reading token for secondary copy; `--muted` measures below
|
|
711
|
+
AA at this size. */
|
|
712
|
+
.app-none {
|
|
713
|
+
font-size: 13.5px;
|
|
714
|
+
color: var(--text2);
|
|
715
|
+
}
|
|
716
|
+
|
|
657
717
|
/*
|
|
658
718
|
* MEASURED at 360: the form overflowed the viewport by 147px in both themes.
|
|
659
719
|
* Its two-column grid, its 28px side padding and its header row are all fixed,
|
|
@@ -56,7 +56,15 @@
|
|
|
56
56
|
<div class="table-card">
|
|
57
57
|
|
|
58
58
|
<!-- Toolbar row -->
|
|
59
|
-
|
|
59
|
+
<!-- Confirmations AND failures. A suspend that the server refused used
|
|
60
|
+
to reach `console.error` and nothing else, so the control read as
|
|
61
|
+
one that quietly did nothing. -->
|
|
62
|
+
<div
|
|
63
|
+
v-if="savedNote"
|
|
64
|
+
class="saved-note"
|
|
65
|
+
:class="{ 'saved-note-error': noteIsError }"
|
|
66
|
+
:role="noteIsError ? 'alert' : 'status'"
|
|
67
|
+
>
|
|
60
68
|
{{ savedNote }}
|
|
61
69
|
<button class="saved-note-close" @click="savedNote = ''">×</button>
|
|
62
70
|
</div>
|
|
@@ -266,27 +274,74 @@
|
|
|
266
274
|
</div>
|
|
267
275
|
</div>
|
|
268
276
|
|
|
269
|
-
<!--
|
|
270
|
-
|
|
277
|
+
<!--
|
|
278
|
+
SUSPEND / REACTIVATE CONFIRMATION.
|
|
279
|
+
|
|
280
|
+
One dialog for both directions, because one endpoint serves both. The
|
|
281
|
+
suspend wording says what actually happens in the owner's own terms
|
|
282
|
+
(owner decision 4) rather than "are you sure": the people who lose access
|
|
283
|
+
are not only the client's office staff, and the two things a person most
|
|
284
|
+
needs to know before pressing it are that nothing is deleted and that it
|
|
285
|
+
can be undone. Reactivating needs no warning, but it still confirms - it
|
|
286
|
+
is a real change to who can sign in.
|
|
287
|
+
-->
|
|
288
|
+
<div v-if="confirmItem" class="overlay" @click.self="cancelConfirm">
|
|
271
289
|
<div class="confirm-box">
|
|
272
|
-
<
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
290
|
+
<template v-if="confirmAction === 'suspended'">
|
|
291
|
+
<h3 class="confirm-title">Suspend {{ confirmItem?.name }}?</h3>
|
|
292
|
+
<p class="confirm-text">
|
|
293
|
+
Everyone who belongs to this client will be blocked from signing in
|
|
294
|
+
— their staff, their residents and their guards.
|
|
295
|
+
</p>
|
|
296
|
+
<p class="confirm-text">
|
|
297
|
+
Nothing is deleted. All of their data is kept.
|
|
298
|
+
</p>
|
|
299
|
+
<p class="confirm-text">
|
|
300
|
+
You can reactivate them at any time and they get straight back in.
|
|
301
|
+
</p>
|
|
302
|
+
</template>
|
|
303
|
+
|
|
304
|
+
<template v-else>
|
|
305
|
+
<h3 class="confirm-title">Reactivate {{ confirmItem?.name }}?</h3>
|
|
306
|
+
<p class="confirm-text">
|
|
307
|
+
Their staff, residents and guards will be able to sign in again.
|
|
308
|
+
</p>
|
|
309
|
+
</template>
|
|
310
|
+
|
|
311
|
+
<p v-if="confirmError" class="confirm-error" role="alert">{{ confirmError }}</p>
|
|
312
|
+
|
|
276
313
|
<div class="confirm-actions">
|
|
277
|
-
<button class="btn-cancel" @click="
|
|
278
|
-
<button
|
|
279
|
-
|
|
280
|
-
|
|
314
|
+
<button class="btn-cancel" :disabled="confirmLoading" @click="cancelConfirm">Cancel</button>
|
|
315
|
+
<button
|
|
316
|
+
:class="confirmAction === 'suspended' ? 'btn-confirm-danger' : 'btn-confirm'"
|
|
317
|
+
:disabled="confirmLoading"
|
|
318
|
+
@click="runConfirm"
|
|
319
|
+
>
|
|
320
|
+
<span v-if="confirmLoading" class="spinner spinner-light" />
|
|
321
|
+
<span v-else>{{ confirmAction === 'suspended' ? 'Suspend' : 'Reactivate' }}</span>
|
|
281
322
|
</button>
|
|
282
323
|
</div>
|
|
283
324
|
</div>
|
|
284
325
|
</div>
|
|
285
326
|
|
|
327
|
+
<!--
|
|
328
|
+
THE ROW MENU CARRIES ITS OWN THEME CLASS.
|
|
329
|
+
|
|
330
|
+
It teleports to `<body>`, which is OUTSIDE the `v-application` element
|
|
331
|
+
Vuetify puts `v-theme--light` / `v-theme--dark` on - and every design
|
|
332
|
+
token in `tokens.css` is declared under `[class*="v-theme--"]`. So
|
|
333
|
+
`background: var(--card)` resolved to NOTHING and the menu drew with a
|
|
334
|
+
fully transparent background: measured `rgba(0, 0, 0, 0)` in both themes,
|
|
335
|
+
with the table's own text showing straight through the menu items. Its
|
|
336
|
+
border, ink and hover were dead for the same reason. Naming the theme on
|
|
337
|
+
the element itself brings all of them back without moving the teleport,
|
|
338
|
+
which is what keeps the menu clear of the table's `overflow`.
|
|
339
|
+
-->
|
|
286
340
|
<Teleport to="body">
|
|
287
341
|
<div
|
|
288
342
|
v-if="openMenuId"
|
|
289
343
|
class="dropdown"
|
|
344
|
+
:class="`v-theme--${currentTheme}`"
|
|
290
345
|
:style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
|
|
291
346
|
>
|
|
292
347
|
<button class="dd-item" @click="viewClient(activeMenuItem)">
|
|
@@ -295,20 +350,29 @@
|
|
|
295
350
|
<button class="dd-item" @click="openSubscription(activeMenuItem)">
|
|
296
351
|
{{ activeMenuItem?.sub?.state === 'none' ? 'Set up subscription' : 'Edit subscription' }}
|
|
297
352
|
</button>
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
<
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
353
|
+
<!--
|
|
354
|
+
OWNER ONLY (owner decision 9). Ordinary Seven365 staff may view
|
|
355
|
+
clients and set up subscriptions; only the owner may stop a client's
|
|
356
|
+
people signing in. The server decides this again on every call -
|
|
357
|
+
`requirePlatformOwner` reads the session, not anything the browser
|
|
358
|
+
sends - so this hides a control it does not guard.
|
|
359
|
+
-->
|
|
360
|
+
<template v-if="isOwner">
|
|
361
|
+
<button
|
|
362
|
+
v-if="activeMenuItem?.status === 'suspended'"
|
|
363
|
+
class="dd-item"
|
|
364
|
+
@click="askConfirm(activeMenuItem, 'active')"
|
|
365
|
+
>
|
|
366
|
+
Reactivate
|
|
367
|
+
</button>
|
|
368
|
+
<button
|
|
369
|
+
v-else
|
|
370
|
+
class="dd-item danger"
|
|
371
|
+
@click="askConfirm(activeMenuItem, 'suspended')"
|
|
372
|
+
>
|
|
373
|
+
Suspend
|
|
374
|
+
</button>
|
|
375
|
+
</template>
|
|
312
376
|
</div>
|
|
313
377
|
</Teleport>
|
|
314
378
|
</div>
|
|
@@ -319,6 +383,8 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
|
319
383
|
import useOrg from '../composables/useOrg'
|
|
320
384
|
import useVerification from '../composables/useVerification'
|
|
321
385
|
import useRole from '../composables/useRole'
|
|
386
|
+
import useConsoleTier from '../composables/useConsoleTier'
|
|
387
|
+
import useThemePreference from '../composables/useThemePreference'
|
|
322
388
|
import useUtils from '../composables/useUtils'
|
|
323
389
|
import InvitationClientForm from './InvitationClientForm.vue'
|
|
324
390
|
import ClientDetailForm from './ClientDetailForm.vue'
|
|
@@ -335,6 +401,10 @@ const { getOrganizationsWithSubscription, updateStatus } = useOrg()
|
|
|
335
401
|
const { getVerifications, cancelUserInvitation } = useVerification()
|
|
336
402
|
const { getCustomerSites: getCustomerSitesByOrgId } = useCustomerSite()
|
|
337
403
|
const { getRoleById } = useRole()
|
|
404
|
+
// Owner decision 9. Only decides what is DRAWN - see `utils/console-tier.ts`.
|
|
405
|
+
const { isOwner, load: loadConsoleTier } = useConsoleTier()
|
|
406
|
+
// Only for the teleported menu - see the comment above the Teleport.
|
|
407
|
+
const { current: currentTheme } = useThemePreference()
|
|
338
408
|
// The same helper the admin app's Organizations list uses to print a nature,
|
|
339
409
|
// so both screens say the value the same way while both exist.
|
|
340
410
|
const { formatNature } = useUtils()
|
|
@@ -378,9 +448,12 @@ const selectedClient = ref<any>(null)
|
|
|
378
448
|
const subscriptionClient = ref<any>(null)
|
|
379
449
|
const savedNote = ref('')
|
|
380
450
|
|
|
381
|
-
/* suspend confirmation */
|
|
382
|
-
const
|
|
383
|
-
const
|
|
451
|
+
/* suspend / reactivate confirmation */
|
|
452
|
+
const confirmItem = ref<any>(null)
|
|
453
|
+
const confirmAction = ref<'active' | 'suspended'>('suspended')
|
|
454
|
+
const confirmLoading = ref(false)
|
|
455
|
+
const confirmError = ref('')
|
|
456
|
+
const noteIsError = ref(false)
|
|
384
457
|
|
|
385
458
|
/* ── COMPUTED ── */
|
|
386
459
|
const pageRange = computed(() => {
|
|
@@ -409,37 +482,35 @@ function parseTotalItems(pageRangeStr: string, fallback: number): number {
|
|
|
409
482
|
return match ? Number(match[1]) : fallback
|
|
410
483
|
}
|
|
411
484
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
485
|
+
/**
|
|
486
|
+
* Say what went wrong, in the words the API used where it gave any.
|
|
487
|
+
*
|
|
488
|
+
* `updateStatus` is owner-gated on the server, so the refusal an ordinary
|
|
489
|
+
* staff member gets here is a real, expected answer - not a crash. It used to
|
|
490
|
+
* land in `console.error`, which meant pressing Suspend looked exactly like
|
|
491
|
+
* pressing nothing.
|
|
492
|
+
*/
|
|
493
|
+
function apiMessage(err: any, fallback: string) {
|
|
494
|
+
return err?.response?._data?.message ?? err?.data?.message ?? fallback
|
|
420
495
|
}
|
|
421
496
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
closeMenu()
|
|
426
|
-
await fetchData()
|
|
427
|
-
} catch (err) {
|
|
428
|
-
console.error(err)
|
|
429
|
-
}
|
|
497
|
+
function showNote(text: string, isError = false) {
|
|
498
|
+
savedNote.value = text
|
|
499
|
+
noteIsError.value = isError
|
|
430
500
|
}
|
|
431
501
|
|
|
432
502
|
/* ── SUBSCRIPTION ── */
|
|
433
503
|
function openSubscription(item: any) {
|
|
434
504
|
if (!item?._id) return
|
|
435
505
|
savedNote.value = ''
|
|
506
|
+
noteIsError.value = false
|
|
436
507
|
subscriptionClient.value = item
|
|
437
508
|
closeMenu()
|
|
438
509
|
}
|
|
439
510
|
|
|
440
511
|
async function onSubscriptionSaved(message: string) {
|
|
441
512
|
subscriptionClient.value = null
|
|
442
|
-
|
|
513
|
+
showNote(message)
|
|
443
514
|
// The list derives every subscription state from the record itself, so
|
|
444
515
|
// re-reading it is all that is needed for the row to tell the truth again.
|
|
445
516
|
await fetchData()
|
|
@@ -453,25 +524,57 @@ async function onSubscriptionSaved(message: string) {
|
|
|
453
524
|
}
|
|
454
525
|
}
|
|
455
526
|
|
|
456
|
-
/* ── SUSPEND CONFIRMATION ── */
|
|
457
|
-
function
|
|
458
|
-
|
|
527
|
+
/* ── SUSPEND / REACTIVATE CONFIRMATION ── */
|
|
528
|
+
function askConfirm(item: any, action: 'active' | 'suspended') {
|
|
529
|
+
if (!item?._id) return
|
|
530
|
+
confirmItem.value = item
|
|
531
|
+
confirmAction.value = action
|
|
532
|
+
confirmError.value = ''
|
|
459
533
|
closeMenu()
|
|
460
534
|
}
|
|
461
535
|
|
|
462
|
-
function
|
|
463
|
-
if (
|
|
464
|
-
|
|
536
|
+
function cancelConfirm() {
|
|
537
|
+
if (confirmLoading.value) return
|
|
538
|
+
confirmItem.value = null
|
|
539
|
+
confirmError.value = ''
|
|
465
540
|
}
|
|
466
541
|
|
|
467
|
-
async function
|
|
468
|
-
|
|
469
|
-
|
|
542
|
+
async function runConfirm() {
|
|
543
|
+
const item = confirmItem.value
|
|
544
|
+
const action = confirmAction.value
|
|
545
|
+
if (!item?._id) return
|
|
546
|
+
|
|
547
|
+
confirmLoading.value = true
|
|
548
|
+
confirmError.value = ''
|
|
549
|
+
|
|
470
550
|
try {
|
|
471
|
-
await
|
|
472
|
-
|
|
551
|
+
await updateStatus(item._id, action)
|
|
552
|
+
confirmItem.value = null
|
|
553
|
+
|
|
554
|
+
// The row's state is derived from the record, never held here, so
|
|
555
|
+
// re-reading the list is the whole update - `describeClientSubscription`
|
|
556
|
+
// then reports "Suspended" from the subscription status the endpoint
|
|
557
|
+
// writes alongside the organisation's. The client also moves to the tab
|
|
558
|
+
// that matches its new status, which is why it leaves this one.
|
|
559
|
+
await fetchData()
|
|
560
|
+
|
|
561
|
+
showNote(
|
|
562
|
+
action === 'suspended'
|
|
563
|
+
? `${item.name} is suspended. Their staff, residents and guards can no longer sign in, and all their data is kept.`
|
|
564
|
+
: `${item.name} is active again. Their staff, residents and guards can sign in.`,
|
|
565
|
+
)
|
|
566
|
+
} catch (err: any) {
|
|
567
|
+
// Stays on the dialog. Closing it and dropping a banner would read as
|
|
568
|
+
// "done, with a note"; the action did not happen and the person is still
|
|
569
|
+
// standing in front of the decision.
|
|
570
|
+
confirmError.value = apiMessage(
|
|
571
|
+
err,
|
|
572
|
+
action === 'suspended'
|
|
573
|
+
? 'Could not suspend this client. Only the Seven365 owner may suspend a client.'
|
|
574
|
+
: 'Could not reactivate this client. Only the Seven365 owner may reactivate a client.',
|
|
575
|
+
)
|
|
473
576
|
} finally {
|
|
474
|
-
|
|
577
|
+
confirmLoading.value = false
|
|
475
578
|
}
|
|
476
579
|
}
|
|
477
580
|
|
|
@@ -574,8 +677,8 @@ function cancelView() {
|
|
|
574
677
|
}
|
|
575
678
|
|
|
576
679
|
async function submitClient() {
|
|
577
|
-
//
|
|
578
|
-
|
|
680
|
+
// `ClientDetailForm` has already saved by the time it emits - this closes the
|
|
681
|
+
// drawer and re-reads the row so the list shows what was saved.
|
|
579
682
|
viewMode.value = 'table'
|
|
580
683
|
fetchData()
|
|
581
684
|
}
|
|
@@ -672,6 +775,9 @@ async function cancelInvite(item: any) {
|
|
|
672
775
|
}
|
|
673
776
|
|
|
674
777
|
onMounted(() => {
|
|
778
|
+
// Not awaited: the list is the screen, and the tier only adds or removes two
|
|
779
|
+
// menu items on a menu nobody has opened yet.
|
|
780
|
+
loadConsoleTier()
|
|
675
781
|
fetchData()
|
|
676
782
|
document.addEventListener('click', handleOutsideClick)
|
|
677
783
|
document.addEventListener('scroll', closeMenu, true)
|
|
@@ -1170,4 +1276,40 @@ tbody td { vertical-align: middle; }
|
|
|
1170
1276
|
}
|
|
1171
1277
|
.btn-confirm-danger:hover:not(:disabled) { background: var(--err); }
|
|
1172
1278
|
.btn-confirm-danger:disabled { opacity: 0.7; cursor: not-allowed; }
|
|
1279
|
+
/* Reactivating is an ordinary action, so it is not dressed as a destructive
|
|
1280
|
+
one. Same box as the danger button so the dialog does not jump between the
|
|
1281
|
+
two directions. */
|
|
1282
|
+
.btn-confirm {
|
|
1283
|
+
display: inline-flex;
|
|
1284
|
+
align-items: center;
|
|
1285
|
+
justify-content: center;
|
|
1286
|
+
gap: 6px;
|
|
1287
|
+
min-width: 78px;
|
|
1288
|
+
padding: 8px 16px;
|
|
1289
|
+
font-size: 13px;
|
|
1290
|
+
font-weight: 500;
|
|
1291
|
+
border: none;
|
|
1292
|
+
border-radius: 8px;
|
|
1293
|
+
background: var(--accent-strong);
|
|
1294
|
+
color: var(--on-accent-strong);
|
|
1295
|
+
cursor: pointer;
|
|
1296
|
+
}
|
|
1297
|
+
.btn-confirm:disabled { opacity: 0.7; cursor: not-allowed; }
|
|
1298
|
+
.btn-cancel:disabled { opacity: 0.7; cursor: not-allowed; }
|
|
1299
|
+
/* The refusal, on the dialog it belongs to. `--err` on `--err-bg`, not a bare
|
|
1300
|
+
red on the card. */
|
|
1301
|
+
.confirm-error {
|
|
1302
|
+
margin-bottom: 16px;
|
|
1303
|
+
padding: 9px 12px;
|
|
1304
|
+
border-radius: 8px;
|
|
1305
|
+
background: var(--err-bg);
|
|
1306
|
+
color: var(--err);
|
|
1307
|
+
font-size: 13px;
|
|
1308
|
+
font-weight: 500;
|
|
1309
|
+
line-height: 1.45;
|
|
1310
|
+
}
|
|
1311
|
+
.saved-note-error {
|
|
1312
|
+
background: var(--err-bg);
|
|
1313
|
+
color: var(--err);
|
|
1314
|
+
}
|
|
1173
1315
|
</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;
|
|
@@ -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
|
+
}
|
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.8-staging.
|
|
5
|
+
"version": "3.2.8-staging.212",
|
|
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.",
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH SEVEN365 TIER THE SIGNED-IN PERSON IS, MIRRORED FROM THE SERVER'S RULE.
|
|
3
|
+
*
|
|
4
|
+
* Owner decision 9 splits Seven365 staff powers in two. Ordinary staff may view
|
|
5
|
+
* clients, set up subscriptions and manage promo codes. **Only the owner may
|
|
6
|
+
* suspend a client** - an action that stops that client's staff, residents and
|
|
7
|
+
* guards signing in.
|
|
8
|
+
*
|
|
9
|
+
* ## The server is the authority. This file only decides what to DRAW.
|
|
10
|
+
*
|
|
11
|
+
* `iservice365-core` `src/utils/super-admin.util.ts` answers the same question
|
|
12
|
+
* on the server, from the session id alone:
|
|
13
|
+
*
|
|
14
|
+
* isSuperAdmin(userId) -> a `members` row `{ type: "admin" }` whose role
|
|
15
|
+
* document is also `type: "admin"`
|
|
16
|
+
* isPlatformOwner(userId) -> the same, AND that role has `default === true`
|
|
17
|
+
*
|
|
18
|
+
* `requirePlatformOwner` (`console-authz.util.ts`) runs `isPlatformOwner`
|
|
19
|
+
* inside `PATCH /api/organizations/:id/status` before it writes anything, and
|
|
20
|
+
* it reads the session - never the request body, never a header the browser
|
|
21
|
+
* chose. So a browser that lies to itself about its tier gets a button it can
|
|
22
|
+
* press and a 401 when it does. Hiding the control is a courtesy, not a lock.
|
|
23
|
+
*
|
|
24
|
+
* ## Why `role.default`, and why nothing here invents a new marker
|
|
25
|
+
*
|
|
26
|
+
* `default` is the only property of a platform-staff role that no API caller
|
|
27
|
+
* can set: `role.controller.ts` validates create/update with Joi object schemas
|
|
28
|
+
* that do not list it (Joi rejects unknown keys), `role.repo.ts` never writes
|
|
29
|
+
* it, and `MRole` defaults it to `false`. Exactly one server path sets it on an
|
|
30
|
+
* `admin`-typed role - `user.service.ts createDefaultUser()` at API boot. Every
|
|
31
|
+
* additional staff account is invited onto a role made through the admin app,
|
|
32
|
+
* which cannot carry it.
|
|
33
|
+
*
|
|
34
|
+
* ## Fail closed
|
|
35
|
+
*
|
|
36
|
+
* Anything this function cannot positively prove is `"none"`. A missing member
|
|
37
|
+
* row, a role that failed to load, a request that threw - all of them draw the
|
|
38
|
+
* ordinary-staff console with no suspend control, which is the recoverable
|
|
39
|
+
* mistake. The other direction is not.
|
|
40
|
+
*
|
|
41
|
+
* Both fields come back from endpoints the app already calls, unprojected:
|
|
42
|
+
* `GET /api/members/user/:id/app/admin` and `GET /api/roles/id/:id`. Nothing
|
|
43
|
+
* new had to be added to the API for this.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export type TConsoleTier = "owner" | "staff" | "none";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One divergence from the server, stated rather than hidden: `isSuperAdmin`
|
|
50
|
+
* matches `status: { $ne: "deleted" }` on the member row, and the endpoint the
|
|
51
|
+
* browser uses (`member.repo.ts getByUserIdType`) matches only `{ user, type }`.
|
|
52
|
+
* So a deleted staff membership can still be handed to this function. It is
|
|
53
|
+
* excluded here too, which keeps the drawn console and the server's answer the
|
|
54
|
+
* same for that case.
|
|
55
|
+
*/
|
|
56
|
+
export function consoleTier(
|
|
57
|
+
member: Record<string, any> | null | undefined,
|
|
58
|
+
role: Record<string, any> | null | undefined,
|
|
59
|
+
): TConsoleTier {
|
|
60
|
+
if (member?.type !== "admin") return "none";
|
|
61
|
+
if (member?.status === "deleted") return "none";
|
|
62
|
+
if (role?.type !== "admin") return "none";
|
|
63
|
+
|
|
64
|
+
// `=== true` exactly as the server writes it. A role document that omits
|
|
65
|
+
// `default` (every role the admin app can create) is staff, not owner.
|
|
66
|
+
return role?.default === true ? "owner" : "staff";
|
|
67
|
+
}
|