@7365admin1/layer-common 4.120.0 → 4.121.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 +13 -4
- package/components/DataRetentionBanner.vue +73 -0
- package/components/MemberSiteAccess.vue +51 -2
- package/components/SiteDataRetention.vue +55 -188
- package/components/SiteSettingsVisitorRegistration.vue +22 -9
- package/components/VisitorForm.vue +5 -1
- package/composables/useDataRetention.ts +8 -2
- package/composables/useMember.ts +6 -0
- package/composables/useVisitor.ts +5 -2
- package/constants/permissions.ts +12 -0
- package/package.json +1 -1
- package/utils/inventory-presets.ts +44 -2
- package/utils/notification-target.ts +10 -0
- package/utils/site-access.ts +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.121.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 19485b6: Data retention is managed by Seven365 Admin: `SiteDataRetention` becomes a read-only summary (rules, dates, removal certificates still download), and the new `DataRetentionBanner` shows staff of the selected site a scheduled permanent deletion (`useDataRetention().getDataRetentionBanner`).
|
|
8
|
+
- 495ae9c: Site Settings > Visitor registration: the NRIC / FIN setting is now a choice of "Ask and keep in full (as usual)", "Ask, but keep only the last 4 characters" or "Don't ask", with help text for each. On a last-4 site the visitor form tells staff that only the last 4 characters will be kept. Needs @7365admin1/core with visitorFields.nric = "last4".
|
|
9
|
+
- 42cdb65: The Security Ops permissions are registered in the canonical vocabulary, so a misspelling is caught rather than silently false. The role editor offers two one-click sets, Security Ops officer and Head of security, and a Security Ops notification now opens the Ops Review page instead of nothing.
|
|
10
|
+
- f663200: Site access has a switch marking a person as part of Security Ops: the officers who go out, walk a site and fill in the visit checklists. It applies to every site ticked in the dialog, shows a chip on each one, and reads the result back in a sentence. It grants nothing on its own, so the Role column still decides what a member may do.
|
|
11
|
+
|
|
3
12
|
## 4.120.0
|
|
4
13
|
|
|
5
14
|
### Minor Changes
|
|
@@ -3209,11 +3218,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3209
3218
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3210
3219
|
own rule from two endpoints the console already calls, unprojected:
|
|
3211
3220
|
|
|
3212
|
-
|
|
3213
|
-
|
|
3221
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3222
|
+
GET /api/roles/id/:role that membership's role document
|
|
3214
3223
|
|
|
3215
|
-
|
|
3216
|
-
|
|
3224
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3225
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3217
3226
|
|
|
3218
3227
|
`role.default` is the marker because it is the only property of a platform
|
|
3219
3228
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Scheduled data removal at the selected site (owner 2026-10-14). Shown to
|
|
4
|
+
the people who work the site, never residents (the server answers nothing
|
|
5
|
+
to them), and only for a removal that will really happen: Seven365 Admin
|
|
6
|
+
allowed deletion at this site and the site's admins were already told.
|
|
7
|
+
A site with no data retention never shows it - the request answers
|
|
8
|
+
`{ banner: null }`.
|
|
9
|
+
-->
|
|
10
|
+
<v-alert
|
|
11
|
+
v-if="banner"
|
|
12
|
+
type="warning"
|
|
13
|
+
variant="tonal"
|
|
14
|
+
density="comfortable"
|
|
15
|
+
class="drb mb-3"
|
|
16
|
+
icon="mdi-delete-clock-outline"
|
|
17
|
+
data-test="drb"
|
|
18
|
+
>
|
|
19
|
+
<div class="drb__text">{{ banner.message }}</div>
|
|
20
|
+
<button type="button" class="drb__toggle" :aria-expanded="open" data-test="drb-details" @click="open = !open">
|
|
21
|
+
{{ open ? "Hide details" : "Details" }}
|
|
22
|
+
</button>
|
|
23
|
+
<div v-if="open" class="drb__details" data-test="drb-details-body">
|
|
24
|
+
<ul class="drb__list">
|
|
25
|
+
<li v-for="i in banner.items" :key="i.dataType">
|
|
26
|
+
{{ i.label }}: {{ Number(i.count).toLocaleString("en-SG") }} created before {{ day(i.cutoff) }}
|
|
27
|
+
</li>
|
|
28
|
+
</ul>
|
|
29
|
+
<div>
|
|
30
|
+
Only finished records are deleted (closed feedback and work orders, visitors who have checked out, decided
|
|
31
|
+
incident reports, past days' occurrence books). Use each list's export to keep a copy. To postpone this, contact
|
|
32
|
+
Seven365.
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</v-alert>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script lang="ts" setup>
|
|
39
|
+
const props = defineProps({
|
|
40
|
+
/** The selected site. Anything that is not a site id shows nothing. */
|
|
41
|
+
siteId: { type: String, default: "" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const { getDataRetentionBanner } = useDataRetention();
|
|
45
|
+
const banner = ref<Record<string, any> | null>(null);
|
|
46
|
+
const open = ref(false);
|
|
47
|
+
|
|
48
|
+
const day = (d: string) =>
|
|
49
|
+
new Date(d).toLocaleDateString("en-SG", { day: "numeric", month: "short", year: "numeric", timeZone: "Asia/Singapore" });
|
|
50
|
+
|
|
51
|
+
async function load(site: string) {
|
|
52
|
+
banner.value = null;
|
|
53
|
+
open.value = false;
|
|
54
|
+
if (!/^[a-f0-9]{24}$/i.test(site)) return;
|
|
55
|
+
try {
|
|
56
|
+
const res = await getDataRetentionBanner(site);
|
|
57
|
+
// A late answer for a site the user already left is dropped.
|
|
58
|
+
if (props.siteId === site) banner.value = res?.banner ?? null;
|
|
59
|
+
} catch {
|
|
60
|
+
// Not reachable, a resident, or an older server: no banner.
|
|
61
|
+
banner.value = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
watch(() => props.siteId, (s) => load(s), { immediate: true });
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<style scoped>
|
|
69
|
+
.drb__text { word-break: break-word; }
|
|
70
|
+
.drb__toggle { text-decoration: underline; font-weight: 600; margin-top: 4px; background: none; border: 0; padding: 0; color: inherit; cursor: pointer; }
|
|
71
|
+
.drb__details { margin-top: 8px; font-size: 13px; }
|
|
72
|
+
.drb__list { padding-left: 18px; margin-bottom: 6px; }
|
|
73
|
+
</style>
|
|
@@ -34,6 +34,33 @@
|
|
|
34
34
|
<p v-if="!choices.length" class="text-body-2">This organisation has no sites yet.</p>
|
|
35
35
|
|
|
36
36
|
<div v-else>
|
|
37
|
+
<!--
|
|
38
|
+
WHO GOES OUT TO THE SITES. One switch for the whole person, not one
|
|
39
|
+
per row: an officer is an officer everywhere they are sent, and the
|
|
40
|
+
server writes the same value onto every site ticked below.
|
|
41
|
+
|
|
42
|
+
It grants NOTHING. What a member may do is still their role, which
|
|
43
|
+
is why this sits apart from the Role column rather than inside it.
|
|
44
|
+
-->
|
|
45
|
+
<v-switch
|
|
46
|
+
v-model="securityOps"
|
|
47
|
+
color="primary"
|
|
48
|
+
density="compact"
|
|
49
|
+
hide-details
|
|
50
|
+
inset
|
|
51
|
+
class="mb-2"
|
|
52
|
+
data-test="site-access-security-ops"
|
|
53
|
+
>
|
|
54
|
+
<template #label>
|
|
55
|
+
<div>
|
|
56
|
+
<div class="text-body-2">This person is part of Security Ops</div>
|
|
57
|
+
<div class="text-caption text-medium-emphasis">
|
|
58
|
+
They visit the sites ticked below and fill in the checklists.
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
62
|
+
</v-switch>
|
|
63
|
+
|
|
37
64
|
<div class="d-flex align-center ga-2 mb-2">
|
|
38
65
|
<v-select
|
|
39
66
|
v-model="sameRole"
|
|
@@ -53,6 +80,7 @@
|
|
|
53
80
|
|
|
54
81
|
<div v-for="(c, i) in choices" :key="c.site" class="d-flex align-center ga-2 py-1" data-test="site-access-row">
|
|
55
82
|
<v-checkbox v-model="choices[i].on" density="compact" hide-details :label="c.name" style="min-width: 220px" />
|
|
83
|
+
<v-chip v-if="securityOps && choices[i].on" size="x-small" color="primary" variant="tonal">Security Ops</v-chip>
|
|
56
84
|
<v-select
|
|
57
85
|
v-if="c.roles.length"
|
|
58
86
|
v-model="choices[i].role"
|
|
@@ -72,6 +100,10 @@
|
|
|
72
100
|
</p>
|
|
73
101
|
</div>
|
|
74
102
|
|
|
103
|
+
<p v-if="choices.length" class="text-caption mt-2" data-test="site-access-security-ops-note">
|
|
104
|
+
{{ securityOpsNote }}
|
|
105
|
+
</p>
|
|
106
|
+
|
|
75
107
|
<p v-if="missing.length" class="text-error text-caption mt-2">Choose a role for: {{ missing.join(", ") }}</p>
|
|
76
108
|
<p v-if="saveError" class="text-error mt-2">{{ saveError }}</p>
|
|
77
109
|
</template>
|
|
@@ -87,7 +119,7 @@
|
|
|
87
119
|
|
|
88
120
|
<script setup lang="ts">
|
|
89
121
|
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
90
|
-
import { applySameRole, initialChoices, roleItems, sharedRoleItems, toPayload, type SiteChoice } from "../utils/site-access";
|
|
122
|
+
import { applySameRole, initialChoices, initialSecurityOps, roleItems, sharedRoleItems, toPayload, type SiteChoice } from "../utils/site-access";
|
|
91
123
|
|
|
92
124
|
const props = defineProps({
|
|
93
125
|
org: { type: String, required: true },
|
|
@@ -106,6 +138,7 @@ const choices = ref<SiteChoice[]>([]);
|
|
|
106
138
|
const orgWide = ref<Array<{ roleName?: string }>>([]);
|
|
107
139
|
const removeOrgAccess = ref(false);
|
|
108
140
|
const sameRole = ref<string | null>(null);
|
|
141
|
+
const securityOps = ref(false);
|
|
109
142
|
|
|
110
143
|
const sharedItems = computed(() => sharedRoleItems(choices.value));
|
|
111
144
|
const anyOn = computed(() => choices.value.some((c) => c.on));
|
|
@@ -119,6 +152,15 @@ function createOrgRole() {
|
|
|
119
152
|
}
|
|
120
153
|
const missing = computed(() => toPayload(choices.value).missing);
|
|
121
154
|
|
|
155
|
+
/** Say back what the switch and the ticks add up to, in the order they read. */
|
|
156
|
+
const securityOpsNote = computed(() => {
|
|
157
|
+
if (!securityOps.value) return "Not part of Security Ops.";
|
|
158
|
+
const names = choices.value.filter((c) => c.on).map((c) => c.name);
|
|
159
|
+
if (!names.length) return "Part of Security Ops, but no site is ticked yet.";
|
|
160
|
+
if (names.length === 1) return `Part of Security Ops at ${names[0]}.`;
|
|
161
|
+
return `Part of Security Ops at ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}.`;
|
|
162
|
+
});
|
|
163
|
+
|
|
122
164
|
function applySame() {
|
|
123
165
|
if (sameRole.value) choices.value = applySameRole(choices.value, sameRole.value);
|
|
124
166
|
}
|
|
@@ -130,6 +172,7 @@ async function load() {
|
|
|
130
172
|
const res = await getSiteAccess(props.org, props.user);
|
|
131
173
|
choices.value = initialChoices(res?.sites ?? []);
|
|
132
174
|
orgWide.value = res?.orgWide ?? [];
|
|
175
|
+
securityOps.value = initialSecurityOps(res?.sites ?? []);
|
|
133
176
|
} catch (e: any) {
|
|
134
177
|
loadError.value = e?.data?.message || "Site access could not be loaded. Only the organisation's owner or an admin can change it.";
|
|
135
178
|
}
|
|
@@ -141,7 +184,13 @@ async function save() {
|
|
|
141
184
|
saveError.value = "";
|
|
142
185
|
try {
|
|
143
186
|
const { sites } = toPayload(choices.value);
|
|
144
|
-
await saveSiteAccess({
|
|
187
|
+
await saveSiteAccess({
|
|
188
|
+
org: props.org,
|
|
189
|
+
user: props.user,
|
|
190
|
+
sites,
|
|
191
|
+
securityOps: securityOps.value,
|
|
192
|
+
removeOrgAccess: removeOrgAccess.value,
|
|
193
|
+
});
|
|
145
194
|
emit("saved", sites.length ? `Site access saved: ${sites.length} site(s).` : "Site access saved: no sites.");
|
|
146
195
|
} catch (e: any) {
|
|
147
196
|
saveError.value = e?.data?.message || "Site access could not be saved.";
|
|
@@ -1,94 +1,48 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<!--
|
|
3
|
-
Data retention (PDPA), per site
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
certificate. Who may change it is decided by the server (the site's owning
|
|
8
|
-
organisation, or Seven365).
|
|
3
|
+
Data retention (PDPA), per site. Since 2026-10-14 (owner) it is managed by
|
|
4
|
+
the Seven365 Super Admin in Seven365 Admin; here it is a read-only summary
|
|
5
|
+
for the site's managers: the rules, the dates, and every removal with its
|
|
6
|
+
certificate. A site that never had it switched on keeps everything, as today.
|
|
9
7
|
-->
|
|
10
8
|
<div class="sdr" data-test="sdr">
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
everything is kept.
|
|
14
|
-
</p>
|
|
15
|
-
|
|
16
|
-
<v-switch
|
|
17
|
-
:model-value="enabled"
|
|
18
|
-
color="primary"
|
|
19
|
-
inset
|
|
20
|
-
hide-details
|
|
21
|
-
label="Remove old records automatically"
|
|
22
|
-
:disabled="!canEdit || saving"
|
|
23
|
-
data-test="sdr-enabled"
|
|
24
|
-
@update:model-value="onToggle"
|
|
25
|
-
/>
|
|
26
|
-
<div v-if="!canEdit" class="sdr-sub" data-test="sdr-owner-only">
|
|
27
|
-
Only the client that owns this site can change data retention.
|
|
9
|
+
<div class="sdr-managed" data-test="sdr-managed">
|
|
10
|
+
Managed by Seven365 Admin. To change how long this site keeps its records, contact Seven365.
|
|
28
11
|
</div>
|
|
29
|
-
<div class="sdr-sub">Before anything is removed, your site admins get a notice with a link to download the records.</div>
|
|
30
12
|
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
<div class="sdr-
|
|
34
|
-
|
|
35
|
-
|
|
13
|
+
<div v-if="!metadata" class="sdr-sub">Loading...</div>
|
|
14
|
+
<template v-else-if="!setting.enabled">
|
|
15
|
+
<div class="sdr-label">Off</div>
|
|
16
|
+
<div class="sdr-sub" data-test="sdr-off">Every record at this site is kept. Nothing is deleted automatically.</div>
|
|
17
|
+
</template>
|
|
18
|
+
<template v-else>
|
|
19
|
+
<div class="sdr-label">On</div>
|
|
20
|
+
<div class="sdr-sub" data-test="sdr-deletion">
|
|
21
|
+
{{
|
|
22
|
+
setting.deletionApproved
|
|
23
|
+
? "Old records are permanently deleted on the dates below."
|
|
24
|
+
: "Requested - waiting for Seven365 approval. Nothing is warned about or deleted until Seven365 approves it."
|
|
25
|
+
}}
|
|
36
26
|
</div>
|
|
37
|
-
|
|
27
|
+
|
|
28
|
+
<div class="sdr-label">How long each kind of record is kept</div>
|
|
29
|
+
<div v-for="t in RETENTION_TYPES" :key="t.value" class="sdr-row" :data-test="`sdr-rule-${t.value}`">
|
|
38
30
|
<span class="sdr-row__name">{{ t.label }}</span>
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
suffix="months"
|
|
45
|
-
density="compact"
|
|
46
|
-
variant="outlined"
|
|
47
|
-
hide-details="auto"
|
|
48
|
-
placeholder="Keep"
|
|
49
|
-
class="sdr-row__field"
|
|
50
|
-
:disabled="!canEdit || saving"
|
|
51
|
-
:rules="[monthRule]"
|
|
52
|
-
:aria-label="`Keep ${t.label} for how many months`"
|
|
53
|
-
:data-test="`sdr-months-${t.value}`"
|
|
54
|
-
/>
|
|
31
|
+
<span>{{ keep(t.value) }}</span>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="sdr-sub mt-2">
|
|
34
|
+
Only finished records are deleted: closed feedback and work orders, visitors who have checked out, decided
|
|
35
|
+
incident reports and past days' occurrence books.
|
|
55
36
|
</div>
|
|
56
37
|
|
|
57
|
-
<div class="sdr-label">
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
suffix="days"
|
|
64
|
-
density="compact"
|
|
65
|
-
variant="outlined"
|
|
66
|
-
hide-details="auto"
|
|
67
|
-
class="sdr-row__field"
|
|
68
|
-
:disabled="!canEdit || saving"
|
|
69
|
-
:rules="[noticeRule]"
|
|
70
|
-
aria-label="Days of notice before removal"
|
|
71
|
-
data-test="sdr-notice"
|
|
72
|
-
/>
|
|
73
|
-
<div class="sdr-sub">Between 7 and 90 days. Records are removed only after this notice.</div>
|
|
38
|
+
<div class="sdr-label">Dates</div>
|
|
39
|
+
<div class="sdr-sub">Your site admins are told {{ setting.noticeDays ?? 14 }} days before each deletion, and reminded the day before.</div>
|
|
40
|
+
<div v-if="setting.startFrom" class="sdr-sub" data-test="sdr-start">Nothing is deleted before {{ day(setting.startFrom) }}.</div>
|
|
41
|
+
<div v-if="pending" class="sdr-sub" data-test="sdr-next">
|
|
42
|
+
<strong>{{ setting.deletionApproved ? "Next deletion" : "Warning sent for" }}: {{ day(pending.dueAt) }}</strong>
|
|
43
|
+
</div>
|
|
74
44
|
</template>
|
|
75
45
|
|
|
76
|
-
<div class="sdr-actions">
|
|
77
|
-
<v-btn class="screen-btn-ghost" variant="outlined" :loading="previewing" :disabled="!enabled || !rules.length" data-test="sdr-preview" @click="runPreview">
|
|
78
|
-
Preview
|
|
79
|
-
</v-btn>
|
|
80
|
-
<v-btn class="screen-btn-primary" variant="flat" color="primary-button" :loading="saving" :disabled="!metadata || !canEdit || !valid || !dirty" data-test="sdr-save" @click="save">
|
|
81
|
-
Save
|
|
82
|
-
</v-btn>
|
|
83
|
-
</div>
|
|
84
|
-
|
|
85
|
-
<div v-if="preview" class="sdr-box" data-test="sdr-preview-result">
|
|
86
|
-
<div class="sdr-box__title">If this ran today, it would remove:</div>
|
|
87
|
-
<ul class="sdr-list">
|
|
88
|
-
<li v-for="i in preview.items" :key="i.dataType">{{ i.count }} {{ i.label.toLowerCase() }} created before {{ day(i.cutoff) }}</li>
|
|
89
|
-
</ul>
|
|
90
|
-
<div class="sdr-sub">Nothing is removed by Preview. The first removal comes only after the notice above.</div>
|
|
91
|
-
</div>
|
|
92
46
|
<div v-if="error" class="sdr-error" role="alert">{{ error }}</div>
|
|
93
47
|
|
|
94
48
|
<div class="sdr-label mt-6">Data removal records</div>
|
|
@@ -115,127 +69,45 @@
|
|
|
115
69
|
</v-btn>
|
|
116
70
|
</div>
|
|
117
71
|
</div>
|
|
118
|
-
|
|
119
|
-
<ConfirmDialog v-model="confirmOn">
|
|
120
|
-
<template #title>Remove old records automatically?</template>
|
|
121
|
-
<template #description>
|
|
122
|
-
Records older than the times you set will be permanently deleted at this site. Your site admins will be told
|
|
123
|
-
{{ noticeNumber }} days before each removal, with a link to download them first. Deleted records cannot be
|
|
124
|
-
brought back.
|
|
125
|
-
</template>
|
|
126
|
-
<template #footer>
|
|
127
|
-
<v-btn class="screen-btn-ghost" variant="outlined" @click="confirmOn = false">Cancel</v-btn>
|
|
128
|
-
<v-btn class="screen-btn-primary" variant="flat" color="primary-button" data-test="sdr-confirm" @click="turnOn">
|
|
129
|
-
Turn on
|
|
130
|
-
</v-btn>
|
|
131
|
-
</template>
|
|
132
|
-
</ConfirmDialog>
|
|
133
72
|
</template>
|
|
134
73
|
|
|
135
74
|
<script lang="ts" setup>
|
|
136
|
-
import { RETENTION_TYPES
|
|
75
|
+
import { RETENTION_TYPES } from "../composables/useDataRetention";
|
|
137
76
|
|
|
138
77
|
const props = defineProps({
|
|
139
78
|
siteId: { type: String, required: true },
|
|
140
|
-
/** The site's stored metadata (`{}` once read for a site with none; null = not read yet
|
|
79
|
+
/** The site's stored metadata (`{}` once read for a site with none; null = not read yet). */
|
|
141
80
|
metadata: { type: Object as PropType<Record<string, any> | null>, default: null },
|
|
142
|
-
/**
|
|
81
|
+
/** No longer used: the summary is read-only for everyone. Kept so a host that still passes it renders as before. */
|
|
143
82
|
canEdit: { type: Boolean, default: false },
|
|
144
83
|
});
|
|
145
|
-
const emit = defineEmits<{ (e: "saved"): void; (e: "error", message: string): void }>();
|
|
146
84
|
|
|
147
|
-
const {
|
|
148
|
-
const { previewDataRetention, listDataPurgeRecords, downloadDataPurgeCertificate } = useDataRetention();
|
|
85
|
+
const { listDataPurgeRecords, downloadDataPurgeCertificate } = useDataRetention();
|
|
149
86
|
|
|
150
|
-
const enabled = ref(false);
|
|
151
|
-
const months = reactive<Record<string, string | number>>({});
|
|
152
|
-
const noticeDays = ref<string | number>(14);
|
|
153
|
-
const saving = ref(false);
|
|
154
|
-
const previewing = ref(false);
|
|
155
|
-
const preview = ref<Record<string, any> | null>(null);
|
|
156
|
-
const error = ref("");
|
|
157
|
-
const confirmOn = ref(false);
|
|
158
87
|
const records = ref<Record<string, any>[]>([]);
|
|
159
88
|
const recordsLoading = ref(false);
|
|
160
89
|
const downloading = ref("");
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
function load(meta: Record<string, any> | null) {
|
|
164
|
-
const d = meta?.dataRetention ?? {};
|
|
165
|
-
enabled.value = d.enabled === true;
|
|
166
|
-
for (const t of RETENTION_TYPES) months[t.value] = "";
|
|
167
|
-
for (const r of Array.isArray(d.rules) ? d.rules : []) months[r.dataType] = r.keepMonths;
|
|
168
|
-
noticeDays.value = d.noticeDays ?? 14;
|
|
169
|
-
stored.value = JSON.stringify(value());
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const toInt = (v: unknown) => (v === "" || v === null || v === undefined ? null : Number(v));
|
|
173
|
-
const monthRule = (v: unknown) => {
|
|
174
|
-
const n = toInt(v);
|
|
175
|
-
return n === null || (Number.isInteger(n) && n >= 1 && n <= 120) || "1 to 120 months, or blank";
|
|
176
|
-
};
|
|
177
|
-
const noticeRule = (v: unknown) => {
|
|
178
|
-
const n = toInt(v);
|
|
179
|
-
return (n !== null && Number.isInteger(n) && n >= 7 && n <= 90) || "7 to 90 days";
|
|
180
|
-
};
|
|
181
|
-
const noticeNumber = computed(() => toInt(noticeDays.value) ?? 14);
|
|
182
|
-
|
|
183
|
-
const rules = computed<TRetentionRule[]>(() =>
|
|
184
|
-
RETENTION_TYPES.map((t) => ({ dataType: t.value, keepMonths: toInt(months[t.value]) as number })).filter((r) => r.keepMonths !== null),
|
|
185
|
-
);
|
|
186
|
-
const valid = computed(
|
|
187
|
-
() => RETENTION_TYPES.every((t) => monthRule(months[t.value]) === true) && (!enabled.value || noticeRule(noticeDays.value) === true),
|
|
188
|
-
);
|
|
189
|
-
function value() {
|
|
190
|
-
return enabled.value
|
|
191
|
-
? { enabled: true, rules: rules.value, noticeDays: toInt(noticeDays.value) ?? 14 }
|
|
192
|
-
: { enabled: false, rules: rules.value, ...(toInt(noticeDays.value) ? { noticeDays: toInt(noticeDays.value) } : {}) };
|
|
193
|
-
}
|
|
194
|
-
const dirty = computed(() => JSON.stringify(value()) !== stored.value);
|
|
195
|
-
|
|
196
|
-
watch(() => props.metadata, (m) => load(m), { immediate: true });
|
|
90
|
+
const error = ref("");
|
|
197
91
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
92
|
+
const setting = computed<Record<string, any>>(() => {
|
|
93
|
+
const d = props.metadata?.dataRetention;
|
|
94
|
+
return d && d.enabled === true ? d : { enabled: false };
|
|
95
|
+
});
|
|
96
|
+
const pending = computed(() => records.value.find((r) => r.status === "pending") ?? null);
|
|
202
97
|
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
else enabled.value = false;
|
|
98
|
+
function keep(type: string) {
|
|
99
|
+
const r = (setting.value.rules ?? []).find((x: any) => x.dataType === type);
|
|
100
|
+
return r ? `${r.keepMonths} month${r.keepMonths === 1 ? "" : "s"}` : "Kept";
|
|
207
101
|
}
|
|
208
102
|
|
|
209
103
|
const message = (e: any) => e?.data?.message || e?.message || "Something went wrong. Please try again.";
|
|
210
|
-
const day = (d: string) =>
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
await saveSiteFeature(props.siteId, "dataRetention", value());
|
|
218
|
-
stored.value = JSON.stringify(value());
|
|
219
|
-
emit("saved");
|
|
220
|
-
} catch (e) {
|
|
221
|
-
error.value = message(e);
|
|
222
|
-
emit("error", error.value);
|
|
223
|
-
} finally {
|
|
224
|
-
saving.value = false;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
async function runPreview() {
|
|
229
|
-
previewing.value = true;
|
|
230
|
-
error.value = "";
|
|
231
|
-
try {
|
|
232
|
-
preview.value = await previewDataRetention(props.siteId, { rules: rules.value });
|
|
233
|
-
} catch (e) {
|
|
234
|
-
error.value = message(e);
|
|
235
|
-
} finally {
|
|
236
|
-
previewing.value = false;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
104
|
+
const day = (d: string) =>
|
|
105
|
+
new Date(/^\d{4}-\d{2}-\d{2}$/.test(d) ? `${d}T00:00:00+08:00` : d).toLocaleDateString("en-SG", {
|
|
106
|
+
day: "numeric",
|
|
107
|
+
month: "short",
|
|
108
|
+
year: "numeric",
|
|
109
|
+
timeZone: "Asia/Singapore",
|
|
110
|
+
});
|
|
239
111
|
|
|
240
112
|
function statusText(r: Record<string, any>) {
|
|
241
113
|
if (r.status === "pending") return `Removal planned for ${day(r.dueAt)}`;
|
|
@@ -271,16 +143,11 @@ onMounted(loadRecords);
|
|
|
271
143
|
</script>
|
|
272
144
|
|
|
273
145
|
<style scoped>
|
|
274
|
-
.sdr-
|
|
146
|
+
.sdr-managed { font-weight: 600; margin-bottom: 8px; }
|
|
275
147
|
.sdr-sub { font-size: 13px; opacity: 0.75; margin-bottom: 8px; }
|
|
276
148
|
.sdr-label { font-weight: 600; margin-top: 16px; }
|
|
277
|
-
.sdr-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
|
149
|
+
.sdr-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 4px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.06); }
|
|
278
150
|
.sdr-row__name { flex: 1 1 160px; min-width: 140px; }
|
|
279
|
-
.sdr-row__field { flex: 0 1 180px; max-width: 180px; min-width: 140px; }
|
|
280
|
-
.sdr-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
|
|
281
|
-
.sdr-box { border: 1px solid rgba(0, 0, 0, 0.12); border-radius: 8px; padding: 12px; margin-top: 12px; }
|
|
282
|
-
.sdr-box__title { font-weight: 600; margin-bottom: 4px; }
|
|
283
|
-
.sdr-list { padding-left: 18px; margin-bottom: 6px; }
|
|
284
151
|
.sdr-error { color: rgb(var(--v-theme-error)); margin-top: 8px; }
|
|
285
152
|
.sdr-record { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.08); word-break: break-word; }
|
|
286
153
|
</style>
|
|
@@ -4,16 +4,18 @@
|
|
|
4
4
|
that never saved them shows "as usual" everywhere and behaves as today.
|
|
5
5
|
-->
|
|
6
6
|
<div data-test="visitor-registration-settings">
|
|
7
|
-
<v-
|
|
8
|
-
v-model="
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
<v-select
|
|
8
|
+
v-model="nric"
|
|
9
|
+
:items="nricItems"
|
|
10
|
+
label="NRIC / FIN"
|
|
11
|
+
density="comfortable"
|
|
12
|
+
variant="outlined"
|
|
11
13
|
hide-details
|
|
12
14
|
:disabled="saving || !canManage"
|
|
13
15
|
data-test="visitor-fields-nric"
|
|
14
16
|
/>
|
|
15
|
-
<p class="text-caption text-medium-emphasis mb-4">
|
|
16
|
-
|
|
17
|
+
<p class="text-caption text-medium-emphasis mt-1 mb-4" data-test="visitor-fields-nric-help">
|
|
18
|
+
{{ nricHelp[nric] }} Existing records are not changed.
|
|
17
19
|
</p>
|
|
18
20
|
|
|
19
21
|
<v-select
|
|
@@ -70,6 +72,17 @@ const props = defineProps({
|
|
|
70
72
|
});
|
|
71
73
|
const emit = defineEmits<{ (e: "saved"): void; (e: "message", text: string, color: string): void }>();
|
|
72
74
|
|
|
75
|
+
const nricItems = [
|
|
76
|
+
{ title: "Ask and keep in full (as usual)", value: "default" },
|
|
77
|
+
{ title: "Ask, but keep only the last 4 characters", value: "last4" },
|
|
78
|
+
{ title: "Don't ask", value: "hidden" },
|
|
79
|
+
];
|
|
80
|
+
const nricHelp: Record<string, string> = {
|
|
81
|
+
default: "The full ID number is saved, and staff can find a returning visitor by typing it.",
|
|
82
|
+
last4:
|
|
83
|
+
"The full number is still typed at registration, but only the last 4 characters are saved (e.g. *****567A), for data protection. Returning visitors are then found by those last 4 characters, so check the name before using a suggestion.",
|
|
84
|
+
hidden: "Choose this if your site does not collect ID numbers.",
|
|
85
|
+
};
|
|
73
86
|
const contactItems = [
|
|
74
87
|
{ title: "Ask as usual", value: "default" },
|
|
75
88
|
{ title: "Don't ask", value: "hidden" },
|
|
@@ -80,7 +93,7 @@ const vehicleItems = [
|
|
|
80
93
|
{ title: "Always required", value: "required" },
|
|
81
94
|
];
|
|
82
95
|
|
|
83
|
-
const
|
|
96
|
+
const nric = ref<"default" | "hidden" | "last4">("default");
|
|
84
97
|
const contact = ref("default");
|
|
85
98
|
const vehicle = ref("default");
|
|
86
99
|
const saving = ref(false);
|
|
@@ -89,7 +102,7 @@ watch(
|
|
|
89
102
|
() => props.siteData,
|
|
90
103
|
(site) => {
|
|
91
104
|
const v = getVisitorFieldSwitches(site);
|
|
92
|
-
|
|
105
|
+
nric.value = v.nric;
|
|
93
106
|
contact.value = v.contact;
|
|
94
107
|
vehicle.value = v.vehicle;
|
|
95
108
|
},
|
|
@@ -103,7 +116,7 @@ async function save() {
|
|
|
103
116
|
saving.value = true;
|
|
104
117
|
try {
|
|
105
118
|
await saveSiteFeature(props.siteId, "visitorFields", {
|
|
106
|
-
nric:
|
|
119
|
+
nric: nric.value,
|
|
107
120
|
contact: contact.value,
|
|
108
121
|
vehicle: vehicle.value,
|
|
109
122
|
});
|
|
@@ -48,6 +48,10 @@
|
|
|
48
48
|
</div>
|
|
49
49
|
</v-list>
|
|
50
50
|
</v-menu>
|
|
51
|
+
<p v-if="visitorFieldSwitches.nric === 'last4'" class="text-caption text-medium-emphasis mt-1"
|
|
52
|
+
data-test="visitor-nric-last4-hint">
|
|
53
|
+
{{ NRIC_LAST4_HINT }}
|
|
54
|
+
</p>
|
|
51
55
|
</v-col>
|
|
52
56
|
</v-row>
|
|
53
57
|
</v-col>
|
|
@@ -613,7 +617,7 @@ import useUtils from "../composables/useUtils";
|
|
|
613
617
|
import useSiteSettings from "../composables/useSiteSettings";
|
|
614
618
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
615
619
|
import useBuilding from "../composables/useBuilding";
|
|
616
|
-
import useVisitor, { getVisitorFieldSwitches, VEHICLE_REQUIRED_MESSAGE } from "../composables/useVisitor";
|
|
620
|
+
import useVisitor, { getVisitorFieldSwitches, NRIC_LAST4_HINT, VEHICLE_REQUIRED_MESSAGE } from "../composables/useVisitor";
|
|
617
621
|
import useSiteEntryPassSettings from "../composables/useSiteEntryPassSettings";
|
|
618
622
|
import useAccessManagement from "../composables/useAccessManagement";
|
|
619
623
|
import usePeople from "../composables/usePeople";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PDPA data retention per site (`/api/data-retention`, API-core). The setting
|
|
3
|
-
* itself is one site switch (`metadata.dataRetention
|
|
3
|
+
* itself is one site switch (`metadata.dataRetention`), managed by the Seven365
|
|
4
|
+
* Super Admin in Seven365 Admin since 2026-10-14; the apps only read it.
|
|
4
5
|
* Documents are not part of data retention in this version.
|
|
5
6
|
*/
|
|
6
7
|
export type TRetentionRule = { dataType: string; keepMonths: number };
|
|
@@ -39,5 +40,10 @@ export default function useDataRetention() {
|
|
|
39
40
|
window.URL.revokeObjectURL(url);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
/** A scheduled removal at the site, for the people who work it (`{ banner: null }` when none). */
|
|
44
|
+
function getDataRetentionBanner(site: string) {
|
|
45
|
+
return api()<{ banner: Record<string, any> | null }>("/api/data-retention/banner", { method: "GET", query: { site } });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { previewDataRetention, listDataPurgeRecords, downloadDataPurgeCertificate, getDataRetentionBanner };
|
|
43
49
|
}
|
package/composables/useMember.ts
CHANGED
|
@@ -201,6 +201,12 @@ export default function useMember() {
|
|
|
201
201
|
org: string;
|
|
202
202
|
user: string;
|
|
203
203
|
sites: Array<{ site: string; role: string }>;
|
|
204
|
+
/**
|
|
205
|
+
* Is this person one of the Security Ops officers who visits sites? One
|
|
206
|
+
* value for the whole person, written onto every site they end up with.
|
|
207
|
+
* Omit it and the server leaves the mark exactly as it is.
|
|
208
|
+
*/
|
|
209
|
+
securityOps?: boolean;
|
|
204
210
|
removeOrgAccess?: boolean;
|
|
205
211
|
}) {
|
|
206
212
|
return useNuxtApp().$api<Record<string, any>>("/api/members/site-access", {
|
|
@@ -279,7 +279,7 @@ export default function () {
|
|
|
279
279
|
* Anything a site never saved reads as "default" = today's form.
|
|
280
280
|
*/
|
|
281
281
|
export type TVisitorFieldSwitches = {
|
|
282
|
-
nric: "default" | "hidden";
|
|
282
|
+
nric: "default" | "hidden" | "last4";
|
|
283
283
|
contact: "default" | "hidden" | "required";
|
|
284
284
|
vehicle: "default" | "required";
|
|
285
285
|
};
|
|
@@ -287,11 +287,14 @@ export type TVisitorFieldSwitches = {
|
|
|
287
287
|
export function getVisitorFieldSwitches(site: any): TVisitorFieldSwitches {
|
|
288
288
|
const v = site?.metadata?.visitorFields ?? {};
|
|
289
289
|
return {
|
|
290
|
-
nric: v.nric === "hidden"
|
|
290
|
+
nric: v.nric === "hidden" || v.nric === "last4" ? v.nric : "default",
|
|
291
291
|
contact: v.contact === "hidden" || v.contact === "required" ? v.contact : "default",
|
|
292
292
|
vehicle: v.vehicle === "required" ? "required" : "default",
|
|
293
293
|
};
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/** Shown under the NRIC field on a "keep only the last 4" site. */
|
|
297
|
+
export const NRIC_LAST4_HINT = "Only the last 4 characters will be kept.";
|
|
298
|
+
|
|
296
299
|
/** Same words as the server's refusal, so the guard reads one message. */
|
|
297
300
|
export const VEHICLE_REQUIRED_MESSAGE = "This site requires a vehicle number for every visitor.";
|
package/constants/permissions.ts
CHANGED
|
@@ -342,6 +342,18 @@ export const PERMISSIONS = {
|
|
|
342
342
|
// Owner answer Q10, 2026-09-11. Mirrors `start-virtual-patrol-route`.
|
|
343
343
|
"start-nfc-patrol",
|
|
344
344
|
],
|
|
345
|
+
// Security Ops. Both strings are enforced by the server (API-core
|
|
346
|
+
// `SITE_VISIT_PERMISSIONS`) and read by the security mobile app's own
|
|
347
|
+
// catalogue, so the spelling is maintained by hand in three places and this
|
|
348
|
+
// is the one that makes a typo visible.
|
|
349
|
+
//
|
|
350
|
+
// `see-all-site-visits` also decides who is told about an action: a head of
|
|
351
|
+
// security holds it together with `site-settings:manage-geofence`. A wildcard
|
|
352
|
+
// deliberately does NOT qualify for that, because a wildcard says somebody
|
|
353
|
+
// administers this site, not that they run security at it - measured
|
|
354
|
+
// 2026-10-13, every wildcard holder at a live site was an org owner, a test
|
|
355
|
+
// account or a service provider's administrator.
|
|
356
|
+
"security-ops": ["start-site-visit", "see-all-site-visits"],
|
|
345
357
|
// Owner answer Q9, 2026-09-11: seeing the CCTV wall. Distinct from
|
|
346
358
|
// `site-settings:manage-cctv-camera`, which is camera setup.
|
|
347
359
|
"cctv-mgmt": ["see-all-cctv", "view-recordings"],
|
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.121.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.",
|
|
@@ -56,8 +56,50 @@ export const INVENTORY_PRESETS: TPermissionPreset[] = [
|
|
|
56
56
|
},
|
|
57
57
|
];
|
|
58
58
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Security Ops in plain words: the two jobs the module actually has.
|
|
61
|
+
*
|
|
62
|
+
* WHY A PRESET AND NOT A NEW PERMISSION. Neither of these is a new right: both
|
|
63
|
+
* sets are made of strings that already exist. The preset only spares somebody
|
|
64
|
+
* hunting a permission tree, and that matters here because the alternative
|
|
65
|
+
* people reach for is a wildcard role, which is how a service provider's
|
|
66
|
+
* administrator ends up supervising a security site.
|
|
67
|
+
*
|
|
68
|
+
* GEOFENCING IS NOT IN EITHER SET, and cannot be: `applyPermissionPreset` is
|
|
69
|
+
* scoped to one resource, and `manage-geofence` belongs to `site-settings`.
|
|
70
|
+
* That matters because the owner's definition of a head of security
|
|
71
|
+
* (2026-10-13) is this permission AND geofencing, and the server reads exactly
|
|
72
|
+
* that pair to decide who is told when an action is raised or sent for
|
|
73
|
+
* verification. So the preset's description has to send the administrator to
|
|
74
|
+
* tick the other box, rather than quietly leaving the job half done.
|
|
75
|
+
*
|
|
76
|
+
* `start-site-visit` is deliberately NOT in the head of security set: it is the
|
|
77
|
+
* right to go and do a visit, and somebody who never walks a site should not
|
|
78
|
+
* have to hold it to be told what is happening on one. Holding it as well does
|
|
79
|
+
* not disqualify them.
|
|
80
|
+
*/
|
|
81
|
+
export const SECURITY_OPS_PRESETS: TPermissionPreset[] = [
|
|
82
|
+
{
|
|
83
|
+
key: "officer",
|
|
84
|
+
label: "Security Ops officer",
|
|
85
|
+
description:
|
|
86
|
+
"Goes to the site, works through the visit checklists and checks out. Sees their own work.",
|
|
87
|
+
actions: ["start-site-visit"],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
key: "head",
|
|
91
|
+
label: "Head of security",
|
|
92
|
+
description:
|
|
93
|
+
"Sees every officer's visits and actions. Also tick Manage geofencing under Site settings: that pair is what decides who is told when an action is raised or sent for verification.",
|
|
94
|
+
actions: ["see-all-site-visits"],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
/** Presets per module key. */
|
|
99
|
+
export const PERMISSION_PRESETS: Record<string, TPermissionPreset[]> = {
|
|
100
|
+
inventory: INVENTORY_PRESETS,
|
|
101
|
+
"security-ops": SECURITY_OPS_PRESETS,
|
|
102
|
+
};
|
|
61
103
|
|
|
62
104
|
/**
|
|
63
105
|
* The permission list with `resource`'s grants replaced by `preset`'s actions --
|
|
@@ -204,6 +204,16 @@ const SITE_PAGES: Record<string, TSitePage[]> = {
|
|
|
204
204
|
robotAlert: [{ apps: ["security", "property-management", "hygiene"], page: () => "robot-management/alerts" }],
|
|
205
205
|
// hygiene has its Service Providers page too since 2026-09-28
|
|
206
206
|
serviceProviderInviteAnswer: [{ apps: SERVICE_APPS, page: () => "service-providers" }],
|
|
207
|
+
/**
|
|
208
|
+
* Security Ops: an action was raised, sent for verification, signed off,
|
|
209
|
+
* reopened, or went overdue. Lands on the Ops Review page, which is where the
|
|
210
|
+
* register is read.
|
|
211
|
+
*
|
|
212
|
+
* WITHOUT THIS ENTRY THE ROW IS A DEAD END. `notificationTarget` answers null
|
|
213
|
+
* for a kind it has no page for, so every overdue escalation ever written has
|
|
214
|
+
* opened nothing at all. Adding it repairs those too, not only the new alerts.
|
|
215
|
+
*/
|
|
216
|
+
"security-ops": [{ apps: ["security"], page: () => "security-ops" }],
|
|
207
217
|
};
|
|
208
218
|
SITE_PAGES.myVisitors = SITE_PAGES.visitors;
|
|
209
219
|
|
package/utils/site-access.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type SiteAccessRole = { _id: string; name: string; type?: string; scope?:
|
|
|
8
8
|
export type SiteAccessRow = {
|
|
9
9
|
site: string;
|
|
10
10
|
name: string;
|
|
11
|
-
membership: { role: string; roleName?: string } | null;
|
|
11
|
+
membership: { role: string; roleName?: string; securityOps?: boolean } | null;
|
|
12
12
|
roles: SiteAccessRole[];
|
|
13
13
|
};
|
|
14
14
|
export type SiteChoice = { site: string; name: string; on: boolean; role: string; roles: SiteAccessRole[]; replacedRole?: string };
|
|
@@ -31,6 +31,18 @@ export function initialChoices(rows: SiteAccessRow[]): SiteChoice[] {
|
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Is this person already marked as Security Ops?
|
|
36
|
+
*
|
|
37
|
+
* ONE ANSWER FOR THE WHOLE PERSON, because that is the question the switch
|
|
38
|
+
* asks: an officer is an officer everywhere they are sent. Any marked site
|
|
39
|
+
* means yes, so a person whose rows disagree (only reachable by an older client
|
|
40
|
+
* or a hand edit) reads as marked, and saving settles every row to one value.
|
|
41
|
+
*/
|
|
42
|
+
export function initialSecurityOps(rows: SiteAccessRow[]): boolean {
|
|
43
|
+
return (rows ?? []).some((r) => Boolean(r.membership?.securityOps));
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
/** One site's Role list: "Organisation roles" first, then "<Site> roles". */
|
|
35
47
|
export function roleItems(choice: Pick<SiteChoice, "name" | "roles">): RoleItem[] {
|
|
36
48
|
const org = choice.roles.filter(isOrgRole);
|