@7365admin1/layer-common 4.0.3-staging.235 → 4.0.3

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 CHANGED
@@ -1,5 +1,268 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.0.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 02e84c5: Default AccessCardPreviewDialog's Update, Replace and Delete gates to refused
8
+
9
+ The three remaining fail-open prop defaults in this package. They qualified for
10
+ the sweep in #1277 but sat inside #1276's own diff hunk, so they were deliberately
11
+ deferred until #1276 landed. It has, so they are flipped now.
12
+
13
+ `canUpdate`, `canReplaceAccessCard` and `canDeleteAccessCard` declared
14
+ `default: true`, so a caller that omitted one was handed the Update, Replace or
15
+ Delete control by the shared layer itself.
16
+
17
+ `AccessManagement` is the only caller anywhere — the layer and all eleven web
18
+ apps at `origin/staging` — and it binds all three to its own props, each of which
19
+ is declared with an explicit `default: false`. The bound expressions therefore
20
+ always resolve to a defined value, so the child default never fires and **no user
21
+ loses access**. This closes the trap for the next caller.
22
+
23
+ The contract in `test/gate-defaults.test.mjs` now covers 54 props across 22
24
+ components, up from 51 across 21.
25
+
26
+ - 6ba8bba: BulletinBoardManagement now derives canViewBulletinBoardDetails from its own permission gate when the caller omits it
27
+
28
+ `canViewBulletinBoardDetails` declared `default: true`. Six of the seven apps
29
+ that render this component (`-hygiene`, `-landscape`, `-mechanical-electrical`,
30
+ `-pest-control`, `-pool-mgmt`, `-security`) never bind it, so every user in
31
+ those apps could open the bulletin-board details/edit dialog regardless of
32
+ permission. `useBulletinBoardPermission().canViewBulletinBoardDetails` already
33
+ computed this gate correctly and failed closed -- it just was never called by
34
+ the component.
35
+
36
+ The component now reads that composable itself as the fallback when the prop
37
+ is unbound (`props.canViewBulletinBoardDetails ?? derived.value`). An app that
38
+ already passes the prop explicitly (`-property-management`) is unaffected --
39
+ its value still wins. This closes the gap in-package; no app-side PR needed
40
+ for this one prop.
41
+
42
+ - 3ac2550: Stop the still-frame refusal from blacking out the Virtual Patrol camera wall
43
+
44
+ On production every tile on the CCTV wall drew "Not set up yet" with "No recorder
45
+ is configured for this camera's relay.", while staging — the same build — played
46
+ live pictures. The difference was a recorder entry on the API host, and the live
47
+ picture never touches a recorder: `CameraWallTile.vue` embeds `camera.host`, the
48
+ relay's own player page, browser to relay, with no Seven365 server in the path.
49
+
50
+ `tileView` returned the dead tile on `camera.unavailableReason` before it ever
51
+ consulted `capabilities.liveVideo`. `unavailableReason` is the server's
52
+ **still-frame** refusal (`snapshotRefusalReason`), which genuinely does need a
53
+ recorder; live video does not. So a picture that was available the whole time was
54
+ never asked for.
55
+
56
+ The `liveVideo` capability is now read first, and the refusal only kills the tile
57
+ when the server has not said live video is supported. A camera that genuinely
58
+ cannot show anything — `unsupported`, or a server that never declared the
59
+ capability at all — still renders the honest "Not set up yet" tile with the
60
+ server's own sentence, unchanged.
61
+
62
+ When a tile draws past an outstanding still-frame refusal it carries
63
+ `RELAY_ONLY_NOTE` on its note, so a moving picture above a "Recorder not checked"
64
+ recorder line reads as two separate facts rather than a contradiction. The note
65
+ is used rather than `detail` because `detail` is drawn across the whole tile and
66
+ would cover the picture it is explaining.
67
+
68
+ - 6f9a69d: Make the dashboard's work-order and visitor widgets honour the spelling each app actually grants, and fail closed before the role loads
69
+
70
+ The three apps that render `DashboardMain` spell their resources differently:
71
+ work orders are `work_orders` in `-security`, `workOrder` in
72
+ `-property-management` and `work_order` in this layer's `useCleaningPermission`;
73
+ visitors are `visitor-mgmt` in `-security` and `visitorManagement` in
74
+ `-property-management`. The widget map asked for one spelling apiece, so a
75
+ property-management role granted "see all work orders" and "see all visitors"
76
+ through its own role editor got neither widget. Every spelling in use is now
77
+ listed, the same defensive union the feedback and incident widgets already had.
78
+
79
+ `canViewWidget` also returned `true` when no role was loaded, so the dashboard
80
+ drew every widget and then removed some -- and a failed role fetch left them all
81
+ drawn. It now returns `false`, matching every other gate in the package.
82
+
83
+ - 0ac386f: Forward two permission gates that were declared, passed by the apps, and then dropped
84
+
85
+ `BuildingManagement/units` declares `canUpdateUnit` and `canDeleteUnit`, and both
86
+ `-property-management` and `-security` bind them — but the component never handed
87
+ them to `BuildingUnitFormEdit`, which draws the Update and Delete items. That child
88
+ defaulted both to `true`, so a role denied update-unit or delete-unit still got both
89
+ actions inside the unit preview dialog.
90
+
91
+ `AccessManagement` never passed `can-assign-to-person` to `AccessCardPreviewDialog`,
92
+ which gates the Assign control and also defaulted to `true`. The list view in the
93
+ same file gates its own Assign on `canUpdateAccessCard && canAssignAccessCard`, so
94
+ the dialog was strictly looser than the list beside it. It now applies the same rule.
95
+
96
+ The three child defaults are flipped from `true` to `false` so a caller that forgets
97
+ the prop is refused rather than granted.
98
+
99
+ User-visible effect, and this one takes access away: in `-property-management` and
100
+ `-security`, a role without update-unit or delete-unit no longer sees Update or
101
+ Delete in the unit preview dialog, and a role without update-access-card or
102
+ assign-access-card no longer sees Assign in the card preview dialog. Those controls
103
+ were never meant to be there. Roles that hold the permissions are unchanged.
104
+
105
+ - b4e5dd5: Default every permission-shaped prop to refused instead of allowed
106
+
107
+ 51 gate-shaped props across 21 shared components declared `default: true`. A
108
+ permission prop that defaults open means the shared layer answers "yes" on an
109
+ app's behalf whenever that app forgets to bind it — the same fail-open class
110
+ found in `web-app-org` and in the 25 `web-app-hygiene` gates, expressed as a
111
+ prop default instead of `if (!userAppRole.value) return true`.
112
+
113
+ All 51 are flipped to `default: false`. Every one was first confirmed bound at
114
+ every existing call site — all eleven web apps read at `origin/staging`, plus
115
+ every layer-internal parent — and the bound expression confirmed to resolve on
116
+ that page, because Vue falls back to the default when a bound value is
117
+ `undefined`. 154 bindings checked, none unresolved.
118
+
119
+ **No user loses access from this change.** Every current call site passes an
120
+ explicit value, so the default never fires today; the flip closes the trap for
121
+ the next caller. Props that some app does not bind are deliberately left open —
122
+ flipping those would take a control away from a real user — and are listed in
123
+ the PR as cross-repo follow-ups.
124
+
125
+ The package's 66 runtime permission gates across its 10 permission composables
126
+ were audited in the same sweep and already fail closed; a regression test now
127
+ drives all of them with real non-wildcard role documents to keep it that way.
128
+
129
+ - 050df56: Show the Members row menu when the only permission granted is Assign Role
130
+
131
+ The menu's `v-if` tested suspend, activate and delete only, while the Assign
132
+ Role item inside it tested `assignRole`. A role granted
133
+ `members:assign-member-role` and nothing else therefore got no row menu at all:
134
+ the permission was granted, the item existed, and there was no way to reach it.
135
+ The menu now opens for `assignRole` as well, and still fails closed when none of
136
+ the four is granted.
137
+
138
+ - f7cafee: Stop shipping this layer's own unit-test files in the published package. The
139
+ `files` list included `utils` wholesale, so seventeen `utils/*.test.ts` files
140
+ went out in every tarball. Consuming applications scan the installed layer's
141
+ `utils` folder for route references, and the fixture strings inside
142
+ `utils/breadcrumb.test.ts` were being read as ten real route names that no page
143
+ produces. Excluding `**/*.test.ts` removes the false readings and drops the
144
+ fixtures from what every application downloads. No runtime code changes.
145
+ - 4f69c0e: Add the missing `site-settings` and `toilet_locations` families to the cleaning permission catalogue
146
+
147
+ `useCleaningPermission()`'s catalogue had no `site-settings` key and no
148
+ `toilet_locations` key at all. `hasPermission` resolves an action as
149
+ `catalogue[resource][action]`, so both families answered `false` for every role
150
+ that did not hold `"*"` — on the published `4.0.1` and on `@staging` alike. This
151
+ is the same class as the earlier flat-catalogue fix but a different root cause:
152
+ the resource was missing rather than wrongly shaped, so that fix did not cover it.
153
+
154
+ Action names and nesting are copied from the sibling module catalogues that
155
+ already work — `usePoolMaintenancePermission` and `useLandscapingPermission` both
156
+ spell site settings exactly this way.
157
+
158
+ User-visible effect: in `web-app-hygiene`, a role granted "view site settings" or
159
+ "manage site settings" now actually reaches the Site Settings screen, and
160
+ "create toilet location" is grantable instead of permanently refused. Nobody
161
+ loses access — a full-access role behaved correctly before and is unchanged, and
162
+ a role that was never granted the permission is still refused.
163
+
164
+ - a9bd126: Fix site-settings and bulletin-board permission gates, which only ever opened for a full-access role
165
+
166
+ `useSettingsPermission` and `useBulletinBoardPermission` passed `hasPermission` a
167
+ flat `{action: ...}` map where it looks the action up as `catalogue[resource][action]`.
168
+ `catalogue["site-settings"]` was therefore `undefined` and all eleven site-settings
169
+ gates plus all five bulletin-board gates answered `false` for every role — the only
170
+ way through was the `"*"` short-circuit. Both now wrap the group under its resource
171
+ key, matching the nine composables that were already nested and working.
172
+
173
+ User-visible effect: ticking a site-settings or bulletin-board box in the role
174
+ editor now actually shows the matching panel or control. Previously the Site
175
+ Settings screen came up with only the one ungated panel no matter what was granted.
176
+
177
+ - 7574d24: Keep the Edit/Create Plan dialog's Cancel and Save buttons on screen
178
+ - 51aac24: Remove seven unread gate-shaped props left over from the fail-open sweep
179
+
180
+ While auditing the 26 remaining `default: true` gate props tracked after #1276/#1277/#1278,
181
+ seven were found declared but never read anywhere in their own component — no template
182
+ binding, no `v-if`, no computed, no method body, no forwarding:
183
+
184
+ - `AccessManagement.vue`: `canUpdate`, `canDelete`
185
+ - `BuildingManagement/buildings.vue`: `canDelete`
186
+ - `BuildingManagement/units.vue`: `canUpdate`, `canDelete`
187
+ - `DocumentManagement.vue`: `canUpdate`, `canDelete`
188
+
189
+ Each of these four components separately declares specific, actually-used gates for the
190
+ same actions (e.g. `canUpdateBuilding` / `canDeleteBuilding` on `buildings.vue`, alongside
191
+ the dead bare `canDelete`), so the dead prop was never wired to anything on screen. Deleting
192
+ it changes no behaviour for any caller — it was already inert — and removes a prop whose
193
+ `default: true` looked like a live fail-open gate on a quick read.
194
+
195
+ This is unrelated to the `test/gate-defaults.test.mjs` contract (54 props / 22 components,
196
+ landed in #1277/#1278): none of these seven appear in that list, since that sweep only
197
+ covers props confirmed to be both bound by every caller AND actually read by the component.
198
+ `yarn test` still reports 279 passing, 0 failing.
199
+
200
+ - e9931aa: Remove twelve inert `definePageMeta` calls from components
201
+
202
+ `definePageMeta` is a pages-only compiler macro. In a component it is a no-op,
203
+ so these twelve calls set neither `middleware` nor `memberOnly` — they only read
204
+ as though the component secured itself. `memberOnly` is what makes
205
+ `plugins/secure-member.client.ts` populate `userAppRole`, and without it every
206
+ `can*` gate in the package answers `false`; the flag has to be on the consuming
207
+ page. Deleting the calls changes no behaviour and stops them being mistaken for
208
+ protection that is already in place.
209
+
210
+ - f2f594b: Resolve `userAppRole` once per signed-in session, on every authenticated route
211
+
212
+ The `secure-member` plugin is the only writer of `userAppRole` in the whole
213
+ system, and it returned early unless the route carried `memberOnly: true`. On
214
+ every other page the role stayed `null` for the life of the page, so permission
215
+ gates that default open granted controls to accounts that had no such
216
+ permission, and gates that fail closed denied them to real staff permanently.
217
+
218
+ Role resolution now runs for any authenticated route and is awaited before the
219
+ navigation completes, so no gate is evaluated against an unresolved role. A new
220
+ `userAppRoleStatus` (`"idle" | "loading" | "resolved"`) and `isAppRoleResolved`
221
+ on `useLocalSetup()` let an app tell "not loaded yet" apart from "loaded, and
222
+ this account genuinely holds no member document" — the second is the correct
223
+ final answer for Seven365 staff-console accounts and residents, and is no
224
+ longer a reason to redirect. A failed request stays unresolved and retries on
225
+ the next navigation instead of denying for the rest of the session.
226
+
227
+ The `memberOnly` redirects are unchanged, and no permission gate was touched.
228
+
229
+ - eaa8500: Wire the three dead role permissions, and stop a refused roles list looking like an empty one
230
+
231
+ `RolePermissionMain` declared `canViewRole`, `canViewByRole` and `canUpdateRole`
232
+ and referenced none of them. Ten pages across the estate pass them in good faith,
233
+ so "See all roles", "See role details" and "Update role" were switches that did
234
+ nothing: the row click was ungated and `RolePermissionFormPreviewUpdate` had no
235
+ permission prop at all, showing Edit to everybody. `canViewRole` and
236
+ `canViewByRole` now default to `true`, which is exactly the behaviour the nine
237
+ pages that do not pass them have always had.
238
+
239
+ The roles table also rendered "No data available" for three different situations
240
+ -- you may not see roles, the request failed, and this organisation has none. It
241
+ now says which. And both role forms read `error.response._data.message`
242
+ unguarded, so a network or timeout failure threw inside its own catch and showed
243
+ the user nothing; the message the preview dialog does show was also duplicated in
244
+ the template and printed twice.
245
+
246
+ - 159aa53: ServiceProviderMain now derives its add/invite/view gates from a real permission catalogue entry when the caller omits them
247
+
248
+ `canAddServiceProvider`, `canInviteServiceProvider` and `canViewServiceProviders` all
249
+ declared `default: true`, and no app bound any of the three -- there was no permission
250
+ catalogue key anywhere in the package for an app to bind them to, so every signed-in user
251
+ in every consumer could add, invite and view service providers regardless of role.
252
+
253
+ Added a `service-provider` family (`add-service-provider`, `invite-service-provider`,
254
+ `see-all-service-providers`) to `useCommonPermission`'s catalogue and a new
255
+ `useServiceProviderPermission` composable that reads it, the same shape as the existing
256
+ `useBulletinBoardPermission` / `useSettingsPermission` gates. `ServiceProviderMain` now
257
+ reads that composable as the fallback when a prop is unbound
258
+ (`props.canAddServiceProvider ?? derived.value`, etc.) -- an app that already passes a
259
+ prop explicitly still wins.
260
+
261
+ This closes the gap in-package. It does not add server-side enforcement:
262
+ `service-provider.route.ts` only requires an authenticated session today, the same as
263
+ `bulletin-board.route.ts` -- this catalogue key is the same frontend-only enforcement
264
+ layer the rest of this family already relies on, not a new pattern.
265
+
3
266
  ## 4.0.2
4
267
 
5
268
  ### Patch Changes
@@ -17,11 +280,11 @@
17
280
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
18
281
  own rule from two endpoints the console already calls, unprojected:
19
282
 
20
- GET /api/members/user/:user/app/admin the Seven365 staff membership
21
- GET /api/roles/id/:role that membership's role document
283
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
284
+ GET /api/roles/id/:role that membership's role document
22
285
 
23
- owner = member.type === "admin" && role.type === "admin" && role.default === true
24
- staff = member.type === "admin" && role.type === "admin"
286
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
287
+ staff = member.type === "admin" && role.type === "admin"
25
288
 
26
289
  `role.default` is the marker because it is the only property of a platform
27
290
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -1,12 +1,38 @@
1
1
  export function useLocalSetup() {
2
2
  const userAppRole = useState<TRole | null>("userAppRole", () => null);
3
3
 
4
+ // Where the `userAppRole` lookup has got to. `userAppRole` on its own cannot
5
+ // say this, and conflating the two states below is what caused both halves of
6
+ // the permission bug:
7
+ //
8
+ // "idle" | "loading" -- nobody has resolved a role yet, or one is in
9
+ // flight. NOT an answer. A gate read here must not
10
+ // grant, and an app that cares should render a
11
+ // loading state rather than guess.
12
+ // "resolved" -- the lookup finished. `userAppRole` is either the
13
+ // role, or `null` because this account genuinely
14
+ // holds no member document for this app. Seven365
15
+ // staff-console accounts and residents never hold
16
+ // one, and "no role" is the correct final answer for
17
+ // them -- not a failure, and not a reason to redirect.
18
+ //
19
+ // A failed request drops back to "idle", never to "resolved", so a transient
20
+ // blip retries on the next navigation instead of denying for the whole session.
21
+ const userAppRoleStatus = useState<"idle" | "loading" | "resolved">(
22
+ "userAppRoleStatus",
23
+ () => "idle"
24
+ );
25
+
26
+ const isAppRoleResolved = computed(() => userAppRoleStatus.value === "resolved");
27
+
4
28
  const id = useState<string | null>("memberShipOrgId", () => null);
5
29
 
6
30
  const orgNature = useState<string>("orgNature", () => "");
7
31
 
8
32
  return {
9
33
  userAppRole,
34
+ userAppRoleStatus,
35
+ isAppRoleResolved,
10
36
  id,
11
37
  orgNature,
12
38
  };
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.0.3-staging.235",
5
+ "version": "4.0.3",
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.",
@@ -4,20 +4,82 @@ const hexSchema = z
4
4
  .string()
5
5
  .regex(/^[0-9a-fA-F]{24}$/, "Invalid organization ID");
6
6
 
7
+ function statusOf(error: any) {
8
+ return error?.statusCode ?? error?.status ?? error?.response?.status;
9
+ }
10
+
7
11
  export default defineNuxtPlugin(() => {
8
12
  const router = useRouter();
9
13
  const { getByUserType } = useMember();
10
14
  const { getRoleById } = useRole();
11
15
  const { getById } = useOrg();
12
16
 
13
- const { userAppRole, id, orgNature } = useLocalSetup();
17
+ const { userAppRole, userAppRoleStatus, id, orgNature } = useLocalSetup();
18
+
19
+ // Resolved once per signed-in session, not once per navigation. Keyed by the
20
+ // user cookie so signing in as somebody else re-resolves.
21
+ let resolvedFor: string | null = null;
22
+ let inFlight: Promise<void> | null = null;
23
+ let hasMemberDocument = false;
24
+
25
+ // Deliberately never redirects and never throws.
26
+ //
27
+ // `GET /api/members/user/:id/app/:type` returns 404 for any account holding no
28
+ // member document for this app -- the Seven365 staff console and residents
29
+ // both do -- and that is a real answer ("no organisation role"), not an error.
30
+ // Treating it as an error is what would bounce those accounts off their own
31
+ // pages; leaving it unanswered is what let gates fail open.
32
+ async function resolveAppRole(userId: string) {
33
+ if (resolvedFor === userId) return;
34
+ if (inFlight) return inFlight;
35
+
36
+ userAppRoleStatus.value = "loading";
37
+ inFlight = run(userId).finally(() => {
38
+ inFlight = null;
39
+ });
40
+ return inFlight;
41
+ }
42
+
43
+ async function run(userId: string) {
44
+ const APP = useRuntimeConfig().public.APP;
45
+
46
+ try {
47
+ // The org query is not sent: the endpoint keys on user + app type only, so
48
+ // the role does not depend on which organisation is in the current route.
49
+ const member = await getByUserType(userId, APP);
50
+ hasMemberDocument = true;
51
+ id.value = member.org ?? "";
52
+ userAppRole.value = member.role ? await getRoleById(member.role) : null;
53
+ } catch (error) {
54
+ userAppRole.value = null;
55
+ hasMemberDocument = false;
56
+ // Anything that is not a 404 is transient. Stay unresolved so the next
57
+ // navigation tries again rather than denying for the rest of the session.
58
+ if (statusOf(error) !== 404) {
59
+ userAppRoleStatus.value = "idle";
60
+ return;
61
+ }
62
+ }
63
+
64
+ resolvedFor = userId;
65
+ userAppRoleStatus.value = "resolved";
66
+ }
14
67
 
15
68
  router.beforeEach(async (to) => {
69
+ const userId = useCookie("user").value ?? "";
70
+
71
+ // Resolve for EVERY authenticated route, not only `memberOnly` ones. Gates
72
+ // are read on pages that carry no such flag -- the staff console cannot
73
+ // carry it at all, it has no organisation in the route -- and until this
74
+ // ran there the role stayed null for the life of the page: gates that
75
+ // default open granted everything, gates that fail closed denied everything.
76
+ // Awaited, so no gate is ever evaluated against an unresolved role.
77
+ if (userId) await resolveAppRole(userId);
78
+
16
79
  const isMember = to.meta?.memberOnly;
17
80
 
18
81
  if (!isMember) return;
19
82
 
20
- const APP = useRuntimeConfig().public.APP;
21
83
  const org =
22
84
  (to.params.org as string) || (to.params.organization as string) || "";
23
85
 
@@ -25,27 +87,19 @@ export default defineNuxtPlugin(() => {
25
87
  return { name: "require-organization-membership" };
26
88
  }
27
89
 
28
- const userId = useCookie("user").value ?? "";
29
90
  if (!userId) return { name: "index" };
30
91
 
31
- try {
32
- const userMemberData = await getByUserType(userId, APP, org);
33
- id.value = userMemberData.org ?? "";
34
-
35
- const [orgResult, roleResult] = await Promise.allSettled([
36
- getById(org),
37
- userMemberData.role ? getRoleById(userMemberData.role) : null,
38
- ]);
39
-
40
- if (orgResult.status === "fulfilled" && orgResult.value) {
41
- orgNature.value = orgResult.value.nature ?? "";
42
- }
92
+ // Same bounce as before for an account with no member document. Gated on a
93
+ // finished lookup so a failed request no longer throws the user out.
94
+ if (userAppRoleStatus.value === "resolved" && !hasMemberDocument) {
95
+ return { name: "index" };
96
+ }
43
97
 
44
- if (roleResult.status === "fulfilled" && roleResult.value) {
45
- userAppRole.value = roleResult.value;
46
- }
98
+ try {
99
+ const orgResult = await getById(org);
100
+ if (orgResult) orgNature.value = orgResult.nature ?? "";
47
101
  } catch (error) {
48
- return { name: "index" };
102
+ // Cosmetic only, and it never blocked navigation before either.
49
103
  }
50
104
  });
51
105
  });