@7365admin1/layer-common 4.83.0 → 4.84.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.84.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 57abba1: Suspended access: when the API answers ACCESS_SUSPENDED (every membership of the person is suspended), the app signs out and the sign-in page shows the server's message. Role names are unique per site, so role pickers and the roles table show "Admin · St. Regis Residences" (`roleLabel`), and the Members role chip uses the same "Role · Site" form.
8
+
3
9
  ## 4.83.0
4
10
 
5
11
  ### Minor Changes
@@ -1968,11 +1974,11 @@
1968
1974
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1969
1975
  own rule from two endpoints the console already calls, unprojected:
1970
1976
 
1971
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1972
- GET /api/roles/id/:role that membership's role document
1977
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1978
+ GET /api/roles/id/:role that membership's role document
1973
1979
 
1974
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1975
- staff = member.type === "admin" && role.type === "admin"
1980
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1981
+ staff = member.type === "admin" && role.type === "admin"
1976
1982
 
1977
1983
  `role.default` is the marker because it is the only property of a platform
1978
1984
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -374,7 +374,7 @@
374
374
  v-model="selectedRole"
375
375
  :items="pickerRoles"
376
376
  item-value="_id"
377
- item-title="name"
377
+ :item-title="roleLabel"
378
378
  density="comfortable"
379
379
  :rules="[requiredRule]"
380
380
  :error-messages="
@@ -427,6 +427,7 @@ import {
427
427
  safeReturnTo,
428
428
  limitedSites,
429
429
  withCurrentRole,
430
+ roleLabel,
430
431
  } from "../utils/role";
431
432
  import { formatLastActive } from "../utils/last-active";
432
433
  import { formatMemberDate, memberAccess, memberPerson } from "../utils/member-row";
@@ -58,6 +58,8 @@
58
58
  else. Two kinds share that list and they are governed differently,
59
59
  so the row has to say which it is.
60
60
  -->
61
+ <!-- "Admin · St. Regis Residences": names are unique per site (2026-10-01) -->
62
+ <template #item.name="{ item }">{{ roleLabel(item) }}</template>
61
63
  <template #item.scope="{ item }">
62
64
  <StatusChip
63
65
  :dot="false"
@@ -164,7 +166,7 @@
164
166
  <v-select
165
167
  v-model="memberReassignments[member._id]"
166
168
  :items="deletionPreview.replacementRoles"
167
- item-title="name"
169
+ :item-title="roleLabel"
168
170
  item-value="_id"
169
171
  label="Reassign role"
170
172
  density="comfortable"
@@ -204,7 +206,7 @@
204
206
 
205
207
  <script setup lang="ts">
206
208
  // Relative import: this layer's `utils/` is not auto-imported into consumers.
207
- import { safeReturnTo, showSiteField } from "../utils/role";
209
+ import { roleLabel, safeReturnTo, showSiteField } from "../utils/role";
208
210
  import { holdsPermission } from "../utils/permission-spellings";
209
211
  import {
210
212
  catalogueForOrg,
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.83.0",
5
+ "version": "4.84.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.",
package/plugins/API.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { isPermissionRefusal } from "../utils/permission-refusal";
2
+ import { accessSuspendedMessage, SIGNIN_NOTICE_COOKIE } from "../utils/access-suspended";
2
3
 
3
4
  export default defineNuxtPlugin((nuxtApp) => {
4
5
  const { cookieConfig } = useRuntimeConfig().public;
6
+ let signingOut = false;
5
7
 
6
8
  const api = $fetch.create({
7
9
  baseURL: "/",
@@ -32,6 +34,20 @@ export default defineNuxtPlugin((nuxtApp) => {
32
34
  // 401 + "You do not have permission ..." (vehicle writes and every other
33
35
  // `requireSiteReach` gate). See utils/permission-refusal.ts.
34
36
  onResponseError({ response }) {
37
+ // Every membership suspended (2026-10-01): the server already ended the
38
+ // session. Sign out here and let the sign-in page say why, once.
39
+ const suspended = accessSuspendedMessage((response as any)?._data);
40
+ if (suspended && import.meta.client && !signingOut) {
41
+ signingOut = true;
42
+ void nuxtApp.runWithContext(() => {
43
+ useCookie(SIGNIN_NOTICE_COOKIE, { ...cookieConfig, maxAge: 300 }).value = suspended;
44
+ for (const name of ["sid", "user", "organization"]) useCookie(name, cookieConfig).value = null;
45
+ const { APP_MAIN, APP_NAME } = useRuntimeConfig().public as Record<string, string>;
46
+ if (String(APP_NAME ?? "").toLowerCase() === "main" || !APP_MAIN) return navigateTo("/sign-in");
47
+ window.location.href = `${APP_MAIN}/sign-in`;
48
+ });
49
+ return;
50
+ }
35
51
  if (!isPermissionRefusal(response?.status, (response as any)?._data)) return;
36
52
  if (!import.meta.client) return;
37
53
  const refresh = (nuxtApp as any).$refreshAppRole;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Suspended = no access (owner 2026-10-01). When every membership of the
3
+ * signed-in person is suspended the API answers any request with 401 +
4
+ * `code: "ACCESS_SUSPENDED"` and the message "Your access has been suspended
5
+ * by <org>. Contact your administrator." (core `suspendedSessionGuard`). The
6
+ * session is already dead server-side; the app signs out and the sign-in page
7
+ * (web-app-main) shows the message once, from this cookie.
8
+ */
9
+ export const ACCESS_SUSPENDED_CODE = "ACCESS_SUSPENDED";
10
+ export const SIGNIN_NOTICE_COOKIE = "signin-notice";
11
+
12
+ export function accessSuspendedMessage(data: unknown): string | null {
13
+ const body = data as { code?: unknown; message?: unknown } | null | undefined;
14
+ if (body?.code !== ACCESS_SUSPENDED_CODE) return null;
15
+ return typeof body.message === "string" && body.message
16
+ ? body.message
17
+ : "Your access has been suspended. Contact your administrator.";
18
+ }
package/utils/role.ts CHANGED
@@ -9,6 +9,19 @@
9
9
  export const showSiteField = (siteState: boolean, type: string): boolean =>
10
10
  siteState && type !== "admin";
11
11
 
12
+ /**
13
+ * A role's name with its site, "Admin · St. Regis Residences" (owner
14
+ * 2026-10-01: role names are unique per SITE, so two sites may both have an
15
+ * "Admin" - two independent roles - and every list that can show both must
16
+ * say which is which). Organisation-level roles (no site) show the name alone.
17
+ * `siteName` comes from the role list (`GET /api/roles`, core 2026-10-01).
18
+ */
19
+ export function roleLabel(role: { name?: unknown; siteName?: unknown; site?: unknown } | null | undefined): string {
20
+ const name = typeof role?.name === "string" ? role.name : "";
21
+ const site = typeof role?.siteName === "string" ? role.siteName.trim() : "";
22
+ return role?.site && site ? `${name} · ${site}` : name;
23
+ }
24
+
12
25
  /**
13
26
  * The Assign Role picker's list, with the member's CURRENT role always in it.
14
27
  *
@@ -50,13 +63,13 @@ export function memberRoleChip(
50
63
  const limited = limitedSites(item);
51
64
  if (orgLevel && limited.length) {
52
65
  return {
53
- text: limited.map((l) => `${l.roleName || "No role"} (${l.siteName || "a site"})`).join(", "),
66
+ text: limited.map((l) => `${l.roleName || "No role"} · ${l.siteName || "a site"}`).join(", "),
54
67
  tone: "info",
55
68
  };
56
69
  }
57
70
  if (orgLevel && item?.roleState === "other-site") {
58
71
  const site = typeof item?.roleSiteName === "string" && item.roleSiteName ? item.roleSiteName : "";
59
- return { text: site ? `${name || "Role"} (${site})` : name || "Role", tone: "info" };
72
+ return { text: site ? `${name || "Role"} · ${site}` : name || "Role", tone: "info" };
60
73
  }
61
74
  switch (item?.roleState) {
62
75
  case "missing":