@7365admin1/layer-common 4.43.1 → 4.45.0
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 +17 -4
- package/components/InvitationForm.vue +90 -7
- package/components/InvitationMain.vue +1 -0
- package/components/NotificationBell.vue +2 -12
- package/components/NotificationSettings.vue +188 -5
- package/components/SiteSettings.vue +21 -2
- package/composables/useNotificationPreference.ts +68 -10
- package/composables/useRole.ts +18 -1
- package/package.json +1 -1
- package/pages/notifications/open.vue +85 -0
- package/utils/invite-role-options.ts +22 -0
- package/utils/notification-target.ts +40 -0
- package/utils/site-owner-note.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.45.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c6a8606: Invite member: from inside a site the Site field is fixed to that site (read-only), and the Role list shows only the organisation's own active roles for the invited app and that site, each labelled "Site role" or "Organisation role". Org-less template roles are no longer offered on an invitation into an organisation.
|
|
8
|
+
- 1c0aea4: Settings → Site Information is read-only on a site the organisation serves but does not own: a note says "This site belongs to <Client>. Only they can change its details.", cover photo, description and documents are inert and Save is hidden. Blocks and guard posts stay editable. Driven by `detailsEditable`/`ownerOrgName` on the site read (core); absent on an older API = unchanged.
|
|
9
|
+
|
|
10
|
+
## 4.44.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- 8d20975: Notification Settings: Email per module, with an "Email address for notifications" card (send code, verify, resend, change). New `/notifications/open` page turns a notification email link into the right page, using the bell's own target table.
|
|
15
|
+
|
|
3
16
|
## 4.43.1
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -1496,11 +1509,11 @@
|
|
|
1496
1509
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1497
1510
|
own rule from two endpoints the console already calls, unprojected:
|
|
1498
1511
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1512
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1513
|
+
GET /api/roles/id/:role that membership's role document
|
|
1501
1514
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1515
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1516
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1504
1517
|
|
|
1505
1518
|
`role.default` is the marker because it is the only property of a platform
|
|
1506
1519
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -46,9 +46,10 @@
|
|
|
46
46
|
<v-col cols="12">
|
|
47
47
|
<v-autocomplete
|
|
48
48
|
v-model="invite.role"
|
|
49
|
-
:items="
|
|
50
|
-
item-title="
|
|
49
|
+
:items="roleOptions"
|
|
50
|
+
item-title="title"
|
|
51
51
|
item-value="_id"
|
|
52
|
+
:item-props="(item: any) => ({ subtitle: item.subtitle })"
|
|
52
53
|
:rules="[requiredRule]"
|
|
53
54
|
class="filter-field"
|
|
54
55
|
density="comfortable"
|
|
@@ -61,7 +62,23 @@
|
|
|
61
62
|
<v-row no-gutters>
|
|
62
63
|
<InputLabel class="text-capitalize" title="Site" />
|
|
63
64
|
<v-col cols="12">
|
|
65
|
+
<!--
|
|
66
|
+
Inviting from inside a site invites INTO that site. The owner
|
|
67
|
+
(2026-09-25): other sites in this list were confusing, so the
|
|
68
|
+
site is shown, not chosen.
|
|
69
|
+
-->
|
|
70
|
+
<v-text-field
|
|
71
|
+
v-if="fixedSite"
|
|
72
|
+
:model-value="invite.siteName || 'This site'"
|
|
73
|
+
class="filter-field"
|
|
74
|
+
density="comfortable"
|
|
75
|
+
readonly
|
|
76
|
+
hint="Invitations from this site are for this site."
|
|
77
|
+
persistent-hint
|
|
78
|
+
data-test="invite-fixed-site"
|
|
79
|
+
></v-text-field>
|
|
64
80
|
<v-autocomplete
|
|
81
|
+
v-else
|
|
65
82
|
v-model="invite.site"
|
|
66
83
|
:items="sites"
|
|
67
84
|
class="filter-field"
|
|
@@ -133,6 +150,8 @@ import useCustomerSite from '../composables/useCustomerSite';
|
|
|
133
150
|
import useLocal from '../composables/useLocal';
|
|
134
151
|
import { useLocalSetup } from '../composables/useLocalSetup';
|
|
135
152
|
import useRole from '../composables/useRole';
|
|
153
|
+
import useSite from '../composables/useSite';
|
|
154
|
+
import { inviteRoleOptions } from '../utils/invite-role-options';
|
|
136
155
|
import useUser from '../composables/useUser';
|
|
137
156
|
import useUtils from '../composables/useUtils';
|
|
138
157
|
|
|
@@ -169,6 +188,16 @@ const props = defineProps({
|
|
|
169
188
|
* which is about the site created in step 4) had no way to say so. It is
|
|
170
189
|
* wired to `invite` below.
|
|
171
190
|
*/
|
|
191
|
+
/*
|
|
192
|
+
* The site the invitation is being made FROM (every site-level Invitations
|
|
193
|
+
* page passes it through InvitationMain). When set, the Site field is fixed
|
|
194
|
+
* to it and the Role list is that site's roles plus the organisation-wide
|
|
195
|
+
* ones.
|
|
196
|
+
*/
|
|
197
|
+
siteId: {
|
|
198
|
+
type: String,
|
|
199
|
+
default: "",
|
|
200
|
+
},
|
|
172
201
|
site: {
|
|
173
202
|
type: Object as PropType<TCustomerSite>,
|
|
174
203
|
default: () => ({
|
|
@@ -262,6 +291,18 @@ watch(
|
|
|
262
291
|
},
|
|
263
292
|
{ immediate: true, deep: true }
|
|
264
293
|
)
|
|
294
|
+
|
|
295
|
+
/* The site this form is fixed to, if any: the page's site, or onboarding's. */
|
|
296
|
+
const fixedSite = computed(() => props.siteId || props.site?.site || "")
|
|
297
|
+
|
|
298
|
+
watch(
|
|
299
|
+
() => props.siteId,
|
|
300
|
+
(siteId) => {
|
|
301
|
+
if (!siteId) return
|
|
302
|
+
invite.value.site = siteId
|
|
303
|
+
},
|
|
304
|
+
{ immediate: true }
|
|
305
|
+
)
|
|
265
306
|
// invite.value.app = props.app;
|
|
266
307
|
// invite.value.org = props.org ?? "";
|
|
267
308
|
|
|
@@ -357,6 +398,27 @@ watchEffect(() => {
|
|
|
357
398
|
}
|
|
358
399
|
});
|
|
359
400
|
|
|
401
|
+
/* Name the fixed site: from the organisation's site list, else read it. */
|
|
402
|
+
const { getSiteById } = useSite();
|
|
403
|
+
watch(
|
|
404
|
+
[fixedSite, sites],
|
|
405
|
+
async ([siteId]) => {
|
|
406
|
+
if (!siteId || invite.value.siteName) return
|
|
407
|
+
const listed = sites.value.find((x) => x?.value === siteId)
|
|
408
|
+
if (listed?.title) {
|
|
409
|
+
invite.value.siteName = listed.title
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const site: any = await getSiteById(siteId)
|
|
414
|
+
invite.value.siteName = site?.name || site?.data?.name || ""
|
|
415
|
+
} catch {
|
|
416
|
+
// The field still reads "This site"; the invitation carries the id.
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
{ immediate: true }
|
|
420
|
+
)
|
|
421
|
+
|
|
360
422
|
watchEffect(() => {
|
|
361
423
|
if (hasSite.value) {
|
|
362
424
|
refreshSiteData();
|
|
@@ -384,26 +446,47 @@ const roles = ref<Array<Record<string, any>>>([]);
|
|
|
384
446
|
*/
|
|
385
447
|
const { getInvitableRoles } = useRole();
|
|
386
448
|
|
|
449
|
+
/*
|
|
450
|
+
* 2026-09-25 (owner report, Wing Tai / Winsland House I): the list now asks for
|
|
451
|
+
* the app actually being invited into (`invite.app`, which the App field can
|
|
452
|
+
* change - it used to keep asking for `props.app`) and for the chosen site, so
|
|
453
|
+
* it holds only this organisation's roles for this app and site. The org-less
|
|
454
|
+
* templates are gone from any invitation that names an organisation - the
|
|
455
|
+
* server refuses them there (`core roleInvitable`).
|
|
456
|
+
*/
|
|
387
457
|
const { data: RolesData, refresh: refreshRoles } = await useLazyAsyncData(
|
|
388
|
-
"get-roles-by-type-" + props.org + "-" + props.app,
|
|
458
|
+
"get-roles-by-type-" + props.org + "-" + props.app + "-" + fixedSite.value,
|
|
389
459
|
() =>
|
|
390
460
|
getInvitableRoles({
|
|
391
461
|
org: props.org,
|
|
392
|
-
type: props.app,
|
|
393
|
-
|
|
462
|
+
type: invite.value.app || props.app,
|
|
463
|
+
site: invite.value.site,
|
|
464
|
+
limit: 100,
|
|
394
465
|
}),
|
|
395
|
-
{ watch: [() => props.app] }
|
|
466
|
+
{ watch: [() => props.app, () => invite.value.app, () => invite.value.site] }
|
|
396
467
|
);
|
|
397
468
|
|
|
398
469
|
watchEffect(() => {
|
|
399
470
|
if (RolesData.value) {
|
|
400
471
|
roles.value = RolesData.value.items;
|
|
472
|
+
// A role picked for a different site is not valid for this one.
|
|
473
|
+
if (
|
|
474
|
+
invite.value.role &&
|
|
475
|
+
!roles.value.some((r: any) => String(r._id) === String(invite.value.role))
|
|
476
|
+
) {
|
|
477
|
+
invite.value.role = "";
|
|
478
|
+
}
|
|
401
479
|
}
|
|
402
480
|
});
|
|
403
481
|
|
|
482
|
+
const roleOptions = computed(() => inviteRoleOptions(roles.value));
|
|
483
|
+
|
|
404
484
|
async function handleUpdateApp(value: string) {
|
|
405
485
|
invite.value.role = "";
|
|
406
|
-
|
|
486
|
+
if (!fixedSite.value) {
|
|
487
|
+
invite.value.site = "";
|
|
488
|
+
invite.value.siteName = "";
|
|
489
|
+
}
|
|
407
490
|
await refreshRoles()
|
|
408
491
|
}
|
|
409
492
|
|
|
@@ -137,7 +137,7 @@ import {
|
|
|
137
137
|
timeAgo,
|
|
138
138
|
type TInboxItem,
|
|
139
139
|
} from "../utils/notification-inbox";
|
|
140
|
-
import { chooseNavigation, notificationTarget } from "../utils/notification-target";
|
|
140
|
+
import { appUrlsFrom, chooseNavigation, notificationTarget } from "../utils/notification-target";
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
143
|
* The top-bar notification feed for the web, in every signed-in app.
|
|
@@ -331,17 +331,7 @@ function hasLocalPage(path: string) {
|
|
|
331
331
|
|
|
332
332
|
/** Every web app's URL, keyed like `TAppKey`, from the layer's runtime config. */
|
|
333
333
|
function appUrls() {
|
|
334
|
-
|
|
335
|
-
return {
|
|
336
|
-
org: String(p.APP_ORG ?? ""),
|
|
337
|
-
security: String(p.APP_SECURITY ?? ""),
|
|
338
|
-
"property-management": String(p.APP_PROPERTY_MANAGEMENT ?? ""),
|
|
339
|
-
hygiene: String(p.APP_HYGIENE ?? ""),
|
|
340
|
-
"mechanical-electrical": String(p.APP_MECHANICAL_ELECTRICAL ?? ""),
|
|
341
|
-
"pest-control": String(p.APP_PEST_CONTROL ?? ""),
|
|
342
|
-
landscaping: String(p.APP_LANDSCAPING ?? ""),
|
|
343
|
-
"pool-maintenance": String(p.APP_POOL_MAINTENANCE ?? ""),
|
|
344
|
-
};
|
|
334
|
+
return appUrlsFrom(appConfig?.public as Record<string, unknown>);
|
|
345
335
|
}
|
|
346
336
|
|
|
347
337
|
/** The decision is `chooseNavigation` (unit-tested); this only carries it out. */
|
|
@@ -56,6 +56,90 @@
|
|
|
56
56
|
</AppCard>
|
|
57
57
|
</v-col>
|
|
58
58
|
|
|
59
|
+
<!-- where notification emails go. Only when the server offers email to
|
|
60
|
+
this person; a module's Email switch does nothing until the
|
|
61
|
+
address below is verified. -->
|
|
62
|
+
<v-col v-if="email?.available" cols="12" class="mt-4">
|
|
63
|
+
<AppCard>
|
|
64
|
+
<div class="pa-6">
|
|
65
|
+
<div class="screen-card-title">Email address for notifications</div>
|
|
66
|
+
|
|
67
|
+
<!-- verified, nothing waiting -->
|
|
68
|
+
<template v-if="emailMode === 'verified'">
|
|
69
|
+
<div class="screen-hint summary-line mt-1">
|
|
70
|
+
Emails go to <strong>{{ email.address }}</strong>.
|
|
71
|
+
</div>
|
|
72
|
+
<AppButton variant="ghost" class="mt-4" :disabled="emailBusy" @click="startChange">
|
|
73
|
+
Change
|
|
74
|
+
</AppButton>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<!-- a code is on its way -->
|
|
78
|
+
<template v-else-if="emailMode === 'pending'">
|
|
79
|
+
<div class="screen-hint summary-line mt-1">
|
|
80
|
+
We sent a 6-digit code to <strong>{{ email.pendingAddress }}</strong>.
|
|
81
|
+
Enter it here.
|
|
82
|
+
</div>
|
|
83
|
+
<div v-if="email.verified" class="screen-hint summary-line mt-1">
|
|
84
|
+
Emails still go to {{ email.address }} until then.
|
|
85
|
+
</div>
|
|
86
|
+
<div class="d-flex flex-wrap align-end ga-3 mt-4">
|
|
87
|
+
<AppField
|
|
88
|
+
v-model="codeInput"
|
|
89
|
+
label="Code"
|
|
90
|
+
inputmode="numeric"
|
|
91
|
+
autocomplete="one-time-code"
|
|
92
|
+
maxlength="6"
|
|
93
|
+
aria-label="6-digit code"
|
|
94
|
+
:disabled="emailBusy"
|
|
95
|
+
class="email-code"
|
|
96
|
+
/>
|
|
97
|
+
<AppButton :disabled="emailBusy || codeInput.trim().length !== 6" @click="verifyCode">
|
|
98
|
+
Verify
|
|
99
|
+
</AppButton>
|
|
100
|
+
<AppButton variant="ghost" :disabled="emailBusy" @click="sendCode(email.pendingAddress || '')">
|
|
101
|
+
Resend code
|
|
102
|
+
</AppButton>
|
|
103
|
+
<AppButton variant="ghost" :disabled="emailBusy" @click="startChange">
|
|
104
|
+
Change
|
|
105
|
+
</AppButton>
|
|
106
|
+
</div>
|
|
107
|
+
</template>
|
|
108
|
+
|
|
109
|
+
<!-- no address yet, or changing it -->
|
|
110
|
+
<template v-else>
|
|
111
|
+
<div class="d-flex flex-wrap align-end ga-3 mt-4">
|
|
112
|
+
<AppField
|
|
113
|
+
v-model="addressInput"
|
|
114
|
+
label="Email address"
|
|
115
|
+
type="email"
|
|
116
|
+
autocomplete="email"
|
|
117
|
+
aria-label="Email address for notifications"
|
|
118
|
+
:disabled="emailBusy"
|
|
119
|
+
class="email-address"
|
|
120
|
+
/>
|
|
121
|
+
<AppButton :disabled="emailBusy || !addressInput.trim()" @click="sendCode(addressInput)">
|
|
122
|
+
Send code
|
|
123
|
+
</AppButton>
|
|
124
|
+
<AppButton
|
|
125
|
+
v-if="changing"
|
|
126
|
+
variant="ghost"
|
|
127
|
+
:disabled="emailBusy"
|
|
128
|
+
@click="changing = false"
|
|
129
|
+
>
|
|
130
|
+
Cancel
|
|
131
|
+
</AppButton>
|
|
132
|
+
</div>
|
|
133
|
+
</template>
|
|
134
|
+
|
|
135
|
+
<div v-if="emailError" class="email-error mt-3" role="alert">{{ emailError }}</div>
|
|
136
|
+
<div v-if="!email.verified" class="screen-hint summary-line mt-3">
|
|
137
|
+
Emails start once your address is verified.
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</AppCard>
|
|
141
|
+
</v-col>
|
|
142
|
+
|
|
59
143
|
<!-- one panel per module, collapsed by default so a person with many
|
|
60
144
|
modules gets a short list rather than a wall of switches -->
|
|
61
145
|
<v-col cols="12" class="mt-4">
|
|
@@ -174,9 +258,18 @@
|
|
|
174
258
|
import type {
|
|
175
259
|
TNotificationCategorySetting,
|
|
176
260
|
TNotificationChannelSetting,
|
|
261
|
+
TNotificationEmailSetting,
|
|
177
262
|
} from "../composables/useNotificationPreference";
|
|
178
263
|
|
|
179
|
-
const { getPreferences, savePreferences } =
|
|
264
|
+
const { getPreferences, savePreferences, requestEmailCode, verifyEmailCode } =
|
|
265
|
+
useNotificationPreference();
|
|
266
|
+
|
|
267
|
+
const email = ref<TNotificationEmailSetting | null>(null);
|
|
268
|
+
const addressInput = ref("");
|
|
269
|
+
const codeInput = ref("");
|
|
270
|
+
const changing = ref(false);
|
|
271
|
+
const emailBusy = ref(false);
|
|
272
|
+
const emailError = ref("");
|
|
180
273
|
|
|
181
274
|
const categories = ref<TNotificationCategorySetting[]>([]);
|
|
182
275
|
const loading = ref(true);
|
|
@@ -193,8 +286,20 @@ let confirmAccept: (() => void) | null = null;
|
|
|
193
286
|
const switchableIn = (category: TNotificationCategorySetting) =>
|
|
194
287
|
category.channels.filter((c) => c.supported);
|
|
195
288
|
|
|
289
|
+
/**
|
|
290
|
+
* In the app and Phone alerts. "All on" is measured on these alone: Email is
|
|
291
|
+
* opt-in, so a module with Email off is still "all on" - and no bulk "on"
|
|
292
|
+
* button ever turns Email on (nobody gets email they did not pick one by one).
|
|
293
|
+
*/
|
|
294
|
+
const alertChannelsIn = (category: TNotificationCategorySetting) =>
|
|
295
|
+
switchableIn(category).filter((c) => c.key !== "email");
|
|
296
|
+
|
|
297
|
+
/** What a bulk switch moves: every channel when turning off, never Email when turning on. */
|
|
298
|
+
const bulkChannelsIn = (category: TNotificationCategorySetting, value: boolean) =>
|
|
299
|
+
value ? alertChannelsIn(category) : switchableIn(category);
|
|
300
|
+
|
|
196
301
|
const categoryAllOn = (category: TNotificationCategorySetting) =>
|
|
197
|
-
|
|
302
|
+
alertChannelsIn(category).every((c) => c.enabled);
|
|
198
303
|
|
|
199
304
|
const allOn = computed(
|
|
200
305
|
() =>
|
|
@@ -230,6 +335,7 @@ async function load() {
|
|
|
230
335
|
try {
|
|
231
336
|
const res = await getPreferences();
|
|
232
337
|
categories.value = res.data.categories;
|
|
338
|
+
setEmail(res.data.email);
|
|
233
339
|
} catch {
|
|
234
340
|
loadError.value =
|
|
235
341
|
"We could not load your notification settings. Please refresh the page and try again.";
|
|
@@ -247,6 +353,7 @@ async function persist(previous: TNotificationCategorySetting[]) {
|
|
|
247
353
|
try {
|
|
248
354
|
const res = await savePreferences({ channels: [], categories: categories.value });
|
|
249
355
|
categories.value = res.data.categories;
|
|
356
|
+
if (res.data.email) setEmail(res.data.email);
|
|
250
357
|
notice.value = "Saved.";
|
|
251
358
|
} catch {
|
|
252
359
|
categories.value = previous;
|
|
@@ -276,7 +383,8 @@ function onToggle(
|
|
|
276
383
|
const commit = () => apply(() => (channel.enabled = value));
|
|
277
384
|
|
|
278
385
|
// switching a safety alert OFF is the only thing worth stopping to confirm
|
|
279
|
-
|
|
386
|
+
// Email is opt-in, so turning it off needs no warning.
|
|
387
|
+
if (category.safety && !value && channel.key !== "email") {
|
|
280
388
|
confirmText.value = safetyOffConfirmation(category.label, channel.label);
|
|
281
389
|
confirmAccept = commit;
|
|
282
390
|
confirmOpen.value = true;
|
|
@@ -289,7 +397,7 @@ function onToggle(
|
|
|
289
397
|
function setCategory(category: TNotificationCategorySetting, value: boolean) {
|
|
290
398
|
const commit = () =>
|
|
291
399
|
apply(() => {
|
|
292
|
-
for (const channel of
|
|
400
|
+
for (const channel of bulkChannelsIn(category, value)) channel.enabled = value;
|
|
293
401
|
});
|
|
294
402
|
|
|
295
403
|
if (category.safety && !value) {
|
|
@@ -306,7 +414,7 @@ function setAll(value: boolean) {
|
|
|
306
414
|
const commit = () =>
|
|
307
415
|
apply(() => {
|
|
308
416
|
for (const category of categories.value) {
|
|
309
|
-
for (const channel of
|
|
417
|
+
for (const channel of bulkChannelsIn(category, value)) channel.enabled = value;
|
|
310
418
|
}
|
|
311
419
|
});
|
|
312
420
|
|
|
@@ -320,6 +428,67 @@ function setAll(value: boolean) {
|
|
|
320
428
|
commit();
|
|
321
429
|
}
|
|
322
430
|
|
|
431
|
+
/** "pending" and "verified" show the address; "enter" shows the field. */
|
|
432
|
+
const emailMode = computed(() => {
|
|
433
|
+
if (changing.value || !email.value) return "enter";
|
|
434
|
+
if (email.value.pendingAddress) return "pending";
|
|
435
|
+
if (email.value.verified) return "verified";
|
|
436
|
+
return "enter";
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
function setEmail(next?: TNotificationEmailSetting) {
|
|
440
|
+
email.value = next ?? null;
|
|
441
|
+
if (!addressInput.value) {
|
|
442
|
+
addressInput.value = next?.address || next?.pendingAddress || next?.accountEmail || "";
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The server's own sentence when it has one ("That code is not right..."). */
|
|
447
|
+
function messageOf(err: any, fallback: string) {
|
|
448
|
+
return String(err?.data?.message || err?.response?._data?.message || fallback);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function startChange() {
|
|
452
|
+
changing.value = true;
|
|
453
|
+
emailError.value = "";
|
|
454
|
+
addressInput.value = email.value?.pendingAddress || email.value?.address || email.value?.accountEmail || "";
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function sendCode(address: string) {
|
|
458
|
+
emailBusy.value = true;
|
|
459
|
+
emailError.value = "";
|
|
460
|
+
try {
|
|
461
|
+
const res = await requestEmailCode(address.trim());
|
|
462
|
+
categories.value = res.data.categories;
|
|
463
|
+
setEmail(res.data.email);
|
|
464
|
+
changing.value = false;
|
|
465
|
+
codeInput.value = "";
|
|
466
|
+
notice.value = res.data.email?.pendingAddress ? "Code sent. Check your email." : "Saved.";
|
|
467
|
+
noticeOpen.value = true;
|
|
468
|
+
} catch (err) {
|
|
469
|
+
emailError.value = messageOf(err, "We could not send the code. Please try again.");
|
|
470
|
+
} finally {
|
|
471
|
+
emailBusy.value = false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function verifyCode() {
|
|
476
|
+
emailBusy.value = true;
|
|
477
|
+
emailError.value = "";
|
|
478
|
+
try {
|
|
479
|
+
const res = await verifyEmailCode(codeInput.value.trim());
|
|
480
|
+
categories.value = res.data.categories;
|
|
481
|
+
setEmail(res.data.email);
|
|
482
|
+
codeInput.value = "";
|
|
483
|
+
notice.value = "Email address verified.";
|
|
484
|
+
noticeOpen.value = true;
|
|
485
|
+
} catch (err) {
|
|
486
|
+
emailError.value = messageOf(err, "We could not check that code. Please try again.");
|
|
487
|
+
} finally {
|
|
488
|
+
emailBusy.value = false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
323
492
|
function acceptConfirm() {
|
|
324
493
|
confirmOpen.value = false;
|
|
325
494
|
confirmAccept?.();
|
|
@@ -354,6 +523,20 @@ onMounted(load);
|
|
|
354
523
|
color: var(--text);
|
|
355
524
|
}
|
|
356
525
|
|
|
526
|
+
.email-address {
|
|
527
|
+
flex: 1 1 260px;
|
|
528
|
+
max-width: 360px;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
.email-code {
|
|
532
|
+
width: 140px;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
.email-error {
|
|
536
|
+
font-size: var(--fs-breadcrumb);
|
|
537
|
+
color: rgb(var(--v-theme-error));
|
|
538
|
+
}
|
|
539
|
+
|
|
357
540
|
.channel-note {
|
|
358
541
|
font-family: var(--font-sans);
|
|
359
542
|
font-size: var(--fs-breadcrumb);
|
|
@@ -28,7 +28,17 @@
|
|
|
28
28
|
<v-expansion-panel-text>
|
|
29
29
|
<v-row no-gutters class="d-flex justify-center py-10">
|
|
30
30
|
<v-form class="w-100">
|
|
31
|
-
|
|
31
|
+
<!--
|
|
32
|
+
Owner decision 2026-09-25: a site's details are its owning
|
|
33
|
+
client's. A provider that serves the site sees them read-only;
|
|
34
|
+
the server refuses the save too. Blocks and guard posts below
|
|
35
|
+
stay editable - they are the provider's operational settings.
|
|
36
|
+
-->
|
|
37
|
+
<v-alert v-if="siteDetailsLocked" type="info" variant="tonal" density="compact" class="mb-4"
|
|
38
|
+
data-test="site-info-owner-only">
|
|
39
|
+
{{ siteOwnerNote }}
|
|
40
|
+
</v-alert>
|
|
41
|
+
<v-row :inert="siteDetailsLocked" :class="{ 'opacity-60': siteDetailsLocked }">
|
|
32
42
|
<!-- Cover Photo + Preview -->
|
|
33
43
|
<v-col cols="12" lg="5" class="pt-0">
|
|
34
44
|
<InputLabel title="Cover Photo (displayed in resident app)" class="d-block mb-2" />
|
|
@@ -58,10 +68,12 @@
|
|
|
58
68
|
</v-col>
|
|
59
69
|
|
|
60
70
|
<!-- Submit -->
|
|
61
|
-
<v-col cols="12" class="pt-0 d-flex justify-end">
|
|
71
|
+
<v-col v-if="!siteDetailsLocked" cols="12" class="pt-0 d-flex justify-end">
|
|
62
72
|
<v-btn color="primary-button" class="text-none" size="large" variant="flat" text="Save"
|
|
63
73
|
:loading="savingSiteInfo" @click="saveSiteInfo" />
|
|
64
74
|
</v-col>
|
|
75
|
+
</v-row>
|
|
76
|
+
<v-row>
|
|
65
77
|
|
|
66
78
|
<!-- Divider -->
|
|
67
79
|
<v-col cols="12" class="my-2">
|
|
@@ -290,6 +302,13 @@ const orgId = computed(
|
|
|
290
302
|
() => props.orgId || String((siteData.value as TSite | null)?.orgId ?? "")
|
|
291
303
|
);
|
|
292
304
|
|
|
305
|
+
// The site read says whether this caller may change the site's details (core
|
|
306
|
+
// 3.103). Absent on an older API = unknown, so nothing is locked here.
|
|
307
|
+
const siteDetailsLocked = computed(
|
|
308
|
+
() => (siteData.value as any)?.detailsEditable === false
|
|
309
|
+
);
|
|
310
|
+
const siteOwnerNote = computed(() => siteOwnerOnlyNote((siteData.value as any)?.ownerOrgName));
|
|
311
|
+
|
|
293
312
|
watch(
|
|
294
313
|
siteData,
|
|
295
314
|
(val: TSite) => {
|
|
@@ -32,17 +32,59 @@ export type TNotificationCategorySetting = {
|
|
|
32
32
|
channels: TNotificationChannelSetting[];
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Where notification emails go. Sent only because this screen asks with
|
|
37
|
+
* `?include=email`; `available: false` means email is not offered to this
|
|
38
|
+
* person yet and the screen shows nothing about it.
|
|
39
|
+
*/
|
|
40
|
+
export type TNotificationEmailSetting = {
|
|
41
|
+
available: boolean;
|
|
42
|
+
/** the VERIFIED address emails go to */
|
|
43
|
+
address: string | null;
|
|
44
|
+
verified: boolean;
|
|
45
|
+
verifiedAt: string | null;
|
|
46
|
+
/** an address waiting for its 6-digit code */
|
|
47
|
+
pendingAddress: string | null;
|
|
48
|
+
/** the sign-in address, suggested when nothing is set */
|
|
49
|
+
accountEmail: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
35
52
|
export type TNotificationPreferences = {
|
|
36
53
|
channels: Array<{ key: string; label: string }>;
|
|
37
54
|
categories: TNotificationCategorySetting[];
|
|
55
|
+
email?: TNotificationEmailSetting;
|
|
38
56
|
};
|
|
39
57
|
|
|
40
58
|
const EMPTY: TNotificationPreferences = { channels: [], categories: [] };
|
|
41
59
|
|
|
60
|
+
const PREF_URL = "/api/notification-preferences";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The PUT body: the switches that are OFF for in-app/phone (absent is on), and
|
|
64
|
+
* the modules with Email ON (email is opt-in, so it is sent as its own list).
|
|
65
|
+
*/
|
|
66
|
+
export function preferencesBody(preferences: Pick<TNotificationPreferences, "categories">) {
|
|
67
|
+
const off = preferences.categories.flatMap((category) =>
|
|
68
|
+
category.channels
|
|
69
|
+
.filter((channel) => channel.key !== "email" && channel.supported && !channel.enabled)
|
|
70
|
+
.map((channel) => ({ category: category.key, channel: channel.key })),
|
|
71
|
+
);
|
|
72
|
+
// No Email row on screen (email not offered to this person): send no list,
|
|
73
|
+
// so the server leaves whatever is saved alone.
|
|
74
|
+
const hasEmail = preferences.categories.some((c) => c.channels.some((ch) => ch.key === "email"));
|
|
75
|
+
if (!hasEmail) return { off };
|
|
76
|
+
const email = preferences.categories
|
|
77
|
+
.filter((category) =>
|
|
78
|
+
category.channels.some((c) => c.key === "email" && c.supported && c.enabled),
|
|
79
|
+
)
|
|
80
|
+
.map((category) => category.key);
|
|
81
|
+
return { off, email };
|
|
82
|
+
}
|
|
83
|
+
|
|
42
84
|
export default function useNotificationPreference() {
|
|
43
85
|
function getPreferences() {
|
|
44
86
|
return useNuxtApp().$api<{ data: TNotificationPreferences }>(
|
|
45
|
-
|
|
87
|
+
`${PREF_URL}?include=email`,
|
|
46
88
|
{ method: "GET" },
|
|
47
89
|
);
|
|
48
90
|
}
|
|
@@ -55,19 +97,35 @@ export default function useNotificationPreference() {
|
|
|
55
97
|
* all, and behaves exactly as they did before this screen existed.
|
|
56
98
|
*/
|
|
57
99
|
function savePreferences(preferences: TNotificationPreferences) {
|
|
58
|
-
const off = preferences.categories.flatMap((category) =>
|
|
59
|
-
category.channels
|
|
60
|
-
.filter((channel) => channel.supported && !channel.enabled)
|
|
61
|
-
.map((channel) => ({ category: category.key, channel: channel.key })),
|
|
62
|
-
);
|
|
63
|
-
|
|
64
100
|
return useNuxtApp().$api<{ data: TNotificationPreferences }>(
|
|
65
|
-
|
|
66
|
-
{ method: "PUT", body:
|
|
101
|
+
`${PREF_URL}?include=email`,
|
|
102
|
+
{ method: "PUT", body: preferencesBody(preferences) },
|
|
67
103
|
);
|
|
68
104
|
}
|
|
69
105
|
|
|
70
|
-
|
|
106
|
+
/** Email a 6-digit code to the address the person wants emails at. */
|
|
107
|
+
function requestEmailCode(address: string) {
|
|
108
|
+
return useNuxtApp().$api<{ data: TNotificationPreferences }>(`${PREF_URL}/email`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
body: { address },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The code from that email. Verifies the address; emails start after this. */
|
|
115
|
+
function verifyEmailCode(code: string) {
|
|
116
|
+
return useNuxtApp().$api<{ data: TNotificationPreferences }>(`${PREF_URL}/email/verify`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
body: { code },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
getPreferences,
|
|
124
|
+
savePreferences,
|
|
125
|
+
requestEmailCode,
|
|
126
|
+
verifyEmailCode,
|
|
127
|
+
EMPTY_PREFERENCES: EMPTY,
|
|
128
|
+
};
|
|
71
129
|
}
|
|
72
130
|
|
|
73
131
|
/**
|
package/composables/useRole.ts
CHANGED
|
@@ -73,17 +73,34 @@ export default function useRole() {
|
|
|
73
73
|
limit?: number;
|
|
74
74
|
}
|
|
75
75
|
) {
|
|
76
|
+
/*
|
|
77
|
+
* An invitation INTO an organisation offers that organisation's own roles
|
|
78
|
+
* only (owner, 2026-09-25: the org-less templates "Org Owner", "Org - Admin
|
|
79
|
+
* Doan623"... in a site's Invite member list were never this client's).
|
|
80
|
+
* The server refuses a template on such an invitation (`core
|
|
81
|
+
* roleInvitable`). Templates are still what an invitation naming NO
|
|
82
|
+
* organisation offers, e.g. the console's new-client flows.
|
|
83
|
+
*
|
|
84
|
+
* A role pinned to a site is offered only for THAT site: the server's site
|
|
85
|
+
* filter returns this site's roles plus the org-wide ones, and with no site
|
|
86
|
+
* chosen yet only the org-wide ones are valid.
|
|
87
|
+
*/
|
|
76
88
|
const [tenant, templates] = await Promise.all([
|
|
77
89
|
org
|
|
78
90
|
? getRoles({ search, org, site, type, limit })
|
|
79
91
|
: Promise.resolve({ items: [] } as Record<string, any>),
|
|
80
|
-
|
|
92
|
+
org
|
|
93
|
+
? Promise.resolve({ items: [] } as Record<string, any>)
|
|
94
|
+
: getRoles({ search, type: "organization", limit }),
|
|
81
95
|
]);
|
|
82
96
|
|
|
83
97
|
const seen = new Set<string>();
|
|
84
98
|
const items = [...(tenant?.items ?? []), ...(templates?.items ?? [])].filter(
|
|
85
99
|
(role: Record<string, any>) => {
|
|
86
100
|
if (role?.type === "admin") return false;
|
|
101
|
+
if (role?.status && role.status !== "active") return false;
|
|
102
|
+
const roleSite = String(role?.site ?? "");
|
|
103
|
+
if (roleSite && roleSite !== String(site ?? "")) return false;
|
|
87
104
|
const id = String(role?._id ?? "");
|
|
88
105
|
if (!id || seen.has(id)) return false;
|
|
89
106
|
seen.add(id);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.45.0",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-row no-gutters class="pa-8" justify="center">
|
|
3
|
+
<v-col cols="12" md="6">
|
|
4
|
+
<v-skeleton-loader v-if="!nowhere" type="list-item-two-line" />
|
|
5
|
+
<AppCard v-else>
|
|
6
|
+
<div class="pa-6">
|
|
7
|
+
<div class="screen-card-title">Open it in the app</div>
|
|
8
|
+
<div class="screen-hint mt-2">
|
|
9
|
+
This opens in the iService365 app on your phone.
|
|
10
|
+
</div>
|
|
11
|
+
<AppButton class="mt-6" @click="router.replace('/notification-settings')">
|
|
12
|
+
Notification settings
|
|
13
|
+
</AppButton>
|
|
14
|
+
</div>
|
|
15
|
+
</AppCard>
|
|
16
|
+
</v-col>
|
|
17
|
+
</v-row>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
/**
|
|
22
|
+
* Where the "Open in iService365" button in a notification email lands.
|
|
23
|
+
*
|
|
24
|
+
* The email carries what the notification is about (`?kind=&id=&site=&org=&app=`,
|
|
25
|
+
* core `openLink`); this page turns it into the page for it with the SAME table
|
|
26
|
+
* and the same rules as the bell (`notificationTarget` + `chooseNavigation`), so
|
|
27
|
+
* there is one list of destinations. A link grants nothing: the page it opens
|
|
28
|
+
* still checks the person may see it.
|
|
29
|
+
*/
|
|
30
|
+
import {
|
|
31
|
+
appUrlsFrom,
|
|
32
|
+
chooseNavigation,
|
|
33
|
+
notificationTarget,
|
|
34
|
+
rowFromEmailLink,
|
|
35
|
+
} from "../../utils/notification-target";
|
|
36
|
+
|
|
37
|
+
definePageMeta({
|
|
38
|
+
middleware: ["01-auth"],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const { authenticate, currentUser } = useLocalAuth();
|
|
42
|
+
const route = useRoute();
|
|
43
|
+
const router = useRouter();
|
|
44
|
+
const appConfig = useRuntimeConfig();
|
|
45
|
+
const nowhere = ref(false);
|
|
46
|
+
authenticate();
|
|
47
|
+
|
|
48
|
+
/** The link's organisation, else the person's own default one (as the bell does). */
|
|
49
|
+
async function organizationFor(fromLink: string): Promise<string> {
|
|
50
|
+
if (fromLink) return fromLink;
|
|
51
|
+
if (currentUser.value?.defaultOrg) return String(currentUser.value.defaultOrg);
|
|
52
|
+
try {
|
|
53
|
+
const { cookieConfig } = appConfig.public as any;
|
|
54
|
+
const id = useCookie("user", cookieConfig).value;
|
|
55
|
+
const user = await useNuxtApp().$api<Record<string, any>>(`/api/users/id/${id}`);
|
|
56
|
+
return String(user?.defaultOrg ?? "");
|
|
57
|
+
} catch {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function hasLocalPage(path: string) {
|
|
63
|
+
try {
|
|
64
|
+
const resolved = router.resolve(path);
|
|
65
|
+
return resolved.matched.length > 0 && !resolved.matched.some((m) => m.path.includes("pathMatch"));
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
onMounted(async () => {
|
|
72
|
+
const { row, organization } = rowFromEmailLink(route.query as Record<string, unknown>);
|
|
73
|
+
const nav = chooseNavigation(
|
|
74
|
+
notificationTarget(row, { organization: await organizationFor(organization) }),
|
|
75
|
+
{
|
|
76
|
+
hasRoute: (name) => typeof router.hasRoute === "function" && router.hasRoute(name),
|
|
77
|
+
hasPage: hasLocalPage,
|
|
78
|
+
appUrls: appUrlsFrom(appConfig?.public as Record<string, unknown>),
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
if (nav?.type === "href") return window.location.replace(nav.href);
|
|
82
|
+
if (nav?.type === "route") return router.replace(nav.to as any).catch(() => (nowhere.value = true));
|
|
83
|
+
nowhere.value = true;
|
|
84
|
+
});
|
|
85
|
+
</script>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Invite member Role list, labelled so an inviter can tell a role made for
|
|
3
|
+
* this site from one that applies across the organisation (owner report
|
|
4
|
+
* 2026-09-25). The roles themselves are already scoped by
|
|
5
|
+
* `useRole.getInvitableRoles`.
|
|
6
|
+
*/
|
|
7
|
+
export type TInviteRoleOption = { _id: string; title: string; subtitle: string };
|
|
8
|
+
|
|
9
|
+
export const SITE_ROLE_LABEL = "Site role";
|
|
10
|
+
export const ORG_ROLE_LABEL = "Organisation role";
|
|
11
|
+
|
|
12
|
+
export function inviteRoleOptions(
|
|
13
|
+
roles: Array<Record<string, any>> | null | undefined,
|
|
14
|
+
): TInviteRoleOption[] {
|
|
15
|
+
return (roles ?? [])
|
|
16
|
+
.filter((role) => role && role._id)
|
|
17
|
+
.map((role) => ({
|
|
18
|
+
_id: String(role._id),
|
|
19
|
+
title: String(role.name ?? ""),
|
|
20
|
+
subtitle: role.site ? SITE_ROLE_LABEL : ORG_ROLE_LABEL,
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
@@ -342,3 +342,43 @@ export function chooseNavigation(
|
|
|
342
342
|
const href = externalTargetUrl(target, app.appUrls);
|
|
343
343
|
return href ? { type: "href", href } : null;
|
|
344
344
|
}
|
|
345
|
+
|
|
346
|
+
/** Every web app's URL, keyed like `TAppKey`, from the layer's public runtime config. */
|
|
347
|
+
export function appUrlsFrom(p: Record<string, unknown> | null | undefined): Record<TAppKey, string> {
|
|
348
|
+
const c = p ?? {};
|
|
349
|
+
return {
|
|
350
|
+
org: String(c.APP_ORG ?? ""),
|
|
351
|
+
security: String(c.APP_SECURITY ?? ""),
|
|
352
|
+
"property-management": String(c.APP_PROPERTY_MANAGEMENT ?? ""),
|
|
353
|
+
hygiene: String(c.APP_HYGIENE ?? ""),
|
|
354
|
+
"mechanical-electrical": String(c.APP_MECHANICAL_ELECTRICAL ?? ""),
|
|
355
|
+
"pest-control": String(c.APP_PEST_CONTROL ?? ""),
|
|
356
|
+
landscaping: String(c.APP_LANDSCAPING ?? ""),
|
|
357
|
+
"pool-maintenance": String(c.APP_POOL_MAINTENANCE ?? ""),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The row a notification-email link stands for. The email's button carries
|
|
363
|
+
* `?kind=&id=&site=&org=&app=` (core `openLink`); this turns that back into
|
|
364
|
+
* the same shape a bell row has, so the same table decides where it lands.
|
|
365
|
+
*/
|
|
366
|
+
export function rowFromEmailLink(query: Record<string, unknown>): {
|
|
367
|
+
row: TTargetRow;
|
|
368
|
+
organization: string;
|
|
369
|
+
} {
|
|
370
|
+
const one = (k: string) => {
|
|
371
|
+
const v = query?.[k];
|
|
372
|
+
return String((Array.isArray(v) ? v[0] : v) ?? "").trim();
|
|
373
|
+
};
|
|
374
|
+
const kind = one("kind");
|
|
375
|
+
const id = one("id");
|
|
376
|
+
return {
|
|
377
|
+
row: {
|
|
378
|
+
params: kind || id ? { kind, id, ...(one("org") ? { org: one("org") } : {}) } : null,
|
|
379
|
+
siteId: one("site") || null,
|
|
380
|
+
appSlug: one("app"),
|
|
381
|
+
},
|
|
382
|
+
organization: one("org"),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a service provider is told on a site it serves but does not own
|
|
3
|
+
* (owner decision 2026-09-25: only the owning client changes a site's
|
|
4
|
+
* details). Same words as the server's refusal.
|
|
5
|
+
*/
|
|
6
|
+
export function siteOwnerOnlyNote(ownerName?: string | null): string {
|
|
7
|
+
const name = String(ownerName ?? "").trim() || "another organisation";
|
|
8
|
+
return `This site belongs to ${name}. Only they can change its details.`;
|
|
9
|
+
}
|