@7365admin1/layer-common 4.82.1-staging.469 → 4.82.1-staging.471

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.
@@ -10,6 +10,8 @@
10
10
  application has loaded and the route the router is on. It fetches nothing
11
11
  and navigates nowhere; it is a label.
12
12
  -->
13
+ <!-- Seven365 staff view: persistent banner with Exit (only while one is open). -->
14
+ <StaffViewBanner />
13
15
  <v-app-bar
14
16
  :height="58"
15
17
  flat
@@ -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,
@@ -0,0 +1,67 @@
1
+ <!--
2
+ "SEVEN365 STAFF VIEW" BANNER (owner, 2026-10-01).
3
+
4
+ Shown on every page of every app while this sign-in has a staff view open
5
+ (the server holds it against the session - see `useStaffView`). It cannot be
6
+ dismissed: the only ways out are Exit here, or signing out, and both end the
7
+ view on the server.
8
+ -->
9
+ <template>
10
+ <v-system-bar
11
+ v-if="staffView"
12
+ :height="36"
13
+ color="warning"
14
+ class="staff-view-banner"
15
+ data-test="staff-view-banner"
16
+ role="status"
17
+ >
18
+ <v-icon icon="mdi-shield-account" size="18" class="me-2" />
19
+ <span class="staff-view-banner__text">
20
+ Seven365 staff view — {{ staffView.orgName || "Client" }}<template v-if="staffView.siteName"> · {{ staffView.siteName }}</template>
21
+ </span>
22
+ <v-spacer />
23
+ <v-btn
24
+ size="small"
25
+ variant="flat"
26
+ color="surface"
27
+ :loading="leaving"
28
+ data-test="staff-view-exit"
29
+ @click="exit"
30
+ >
31
+ Exit
32
+ </v-btn>
33
+ </v-system-bar>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ const { staffView, loadStaffView, exitStaffView } = useStaffView();
38
+ const { APP_ORG } = useRuntimeConfig().public;
39
+ const leaving = ref(false);
40
+
41
+ onMounted(() => {
42
+ loadStaffView().catch(() => undefined);
43
+ });
44
+
45
+ async function exit() {
46
+ leaving.value = true;
47
+ try {
48
+ const org = await exitStaffView();
49
+ // Back to the console page staff opened it from.
50
+ window.location.href = `${APP_ORG}/super-admin/client-details${org ? `?org=${org}` : ""}`;
51
+ } catch {
52
+ leaving.value = false;
53
+ }
54
+ }
55
+ </script>
56
+
57
+ <style scoped>
58
+ .staff-view-banner {
59
+ font-weight: 600;
60
+ z-index: 1010;
61
+ }
62
+ .staff-view-banner__text {
63
+ overflow: hidden;
64
+ text-overflow: ellipsis;
65
+ white-space: nowrap;
66
+ }
67
+ </style>
@@ -0,0 +1,49 @@
1
+ /**
2
+ * "OPEN AS SEVEN365 STAFF" (owner, 2026-10-01).
3
+ *
4
+ * The server holds the open view against the session (`staff-view:<sid>`), so
5
+ * this only READS it - nothing here can open one or widen anything. Read once
6
+ * per page load; Exit clears it on the server and here.
7
+ */
8
+ const BASE = "/api/users/staff-access";
9
+
10
+ /** What a staff member is given inside the client they opened: full setup, like the client's owner. */
11
+ export const STAFF_VIEW_ROLE = Object.freeze({
12
+ name: "Seven365 staff",
13
+ permissions: ["*"],
14
+ }) as unknown as TRole;
15
+
16
+ /** Does the open view cover this route's organisation? */
17
+ export function staffViewCovers(view: TStaffView | null | undefined, org: string): boolean {
18
+ return !!view?.org && !!org && view.org === org;
19
+ }
20
+
21
+ export default function useStaffView() {
22
+ const staffView = useState<TStaffView | null>("staff-view", () => null);
23
+ const staffViewLoaded = useState<boolean>("staff-view-loaded", () => false);
24
+
25
+ async function loadStaffView(force = false): Promise<TStaffView | null> {
26
+ if (staffViewLoaded.value && !force) return staffView.value;
27
+ try {
28
+ const res = await useNuxtApp().$api<{ view?: TStaffView | null }>(`${BASE}/view`, {
29
+ method: "GET",
30
+ timeout: 5000,
31
+ });
32
+ staffView.value = res?.view ?? null;
33
+ } catch {
34
+ // No view is the safe answer: staff simply see what they saw before.
35
+ staffView.value = null;
36
+ }
37
+ staffViewLoaded.value = true;
38
+ return staffView.value;
39
+ }
40
+
41
+ async function exitStaffView(): Promise<string> {
42
+ const org = staffView.value?.org ?? "";
43
+ await useNuxtApp().$api(`${BASE}/view/exit`, { method: "POST" });
44
+ staffView.value = null;
45
+ return org;
46
+ }
47
+
48
+ return { staffView, staffViewLoaded, loadStaffView, exitStaffView };
49
+ }
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.82.1-staging.469",
5
+ "version": "4.82.1-staging.471",
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;
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { routeDenied } from "../utils/module-routes.ts";
3
+ import useStaffView, { STAFF_VIEW_ROLE, staffViewCovers } from "../composables/useStaffView.ts";
3
4
 
4
5
  const hexSchema = z
5
6
  .string()
@@ -63,6 +64,13 @@ export default defineNuxtPlugin(() => {
63
64
  const { userAppRole, userAppRoleStatus, userAppModulesDenied, id, orgNature } =
64
65
  useLocalSetup();
65
66
 
67
+ // "Open as Seven365 staff": the server holds the view against the session.
68
+ // While it covers the route's organisation, staff work there with full setup
69
+ // rights instead of their own (absent) membership. Nothing is written.
70
+ const { loadStaffView } = useStaffView();
71
+ let staffViewActive = false;
72
+ let lastStaffOrg = "";
73
+
66
74
  // Resolved once per signed-in session, not once per navigation. Keyed by the
67
75
  // user cookie so signing in as somebody else re-resolves.
68
76
  // Keyed by user AND the site the role was resolved AT, so switching site
@@ -87,8 +95,8 @@ export default defineNuxtPlugin(() => {
87
95
  // both do -- and that is a real answer ("no organisation role"), not an error.
88
96
  // Treating it as an error is what would bounce those accounts off their own
89
97
  // pages; leaving it unanswered is what let gates fail open.
90
- async function resolveAppRole(userId: string, site: string) {
91
- const key = `${userId}|${site}`;
98
+ async function resolveAppRole(userId: string, site: string, staffOrg = "") {
99
+ const key = `${userId}|${site}${staffOrg ? `|staff:${staffOrg}` : ""}`;
92
100
  if (resolvedFor === key) return;
93
101
  if (inFlight) {
94
102
  // A lookup for the SAME key is the one we want: await it and stop, which
@@ -105,15 +113,25 @@ export default defineNuxtPlugin(() => {
105
113
  // question, so the resolved role stays on screen and nothing flickers.
106
114
  if (resolvedFor === null || SEND_SITE) userAppRoleStatus.value = "loading";
107
115
  inFlightKey = key;
108
- inFlight = run(userId, site).finally(() => {
116
+ inFlight = run(userId, site, staffOrg).finally(() => {
109
117
  inFlight = null;
110
118
  });
111
119
  return inFlight;
112
120
  }
113
121
 
114
- async function run(userId: string, site: string) {
122
+ async function run(userId: string, site: string, staffOrg = "") {
115
123
  const APP = useRuntimeConfig().public.APP;
116
124
 
125
+ staffViewActive = !!staffOrg;
126
+ if (staffOrg) {
127
+ hasMemberDocument = true;
128
+ id.value = staffOrg;
129
+ userAppRole.value = STAFF_VIEW_ROLE;
130
+ resolvedFor = `${userId}|${site}|staff:${staffOrg}`;
131
+ userAppRoleStatus.value = "resolved";
132
+ return;
133
+ }
134
+
117
135
  try {
118
136
  // The org query is not sent: without it the server's site rule answers
119
137
  // with the membership pinned to `site` or, failing that, with exactly the
@@ -157,7 +175,8 @@ export default defineNuxtPlugin(() => {
157
175
  // that is not a list all mean nothing is denied -- never "hide everything".
158
176
  // It never touches the role or its status.
159
177
  async function resolveDenied(userId: string, site: string) {
160
- if (!hasMemberDocument) {
178
+ // Seven365 staff are not capped by the client's module list.
179
+ if (!hasMemberDocument || staffViewActive) {
161
180
  deniedFor = deniedWanted = null;
162
181
  userAppModulesDenied.value = [];
163
182
  return;
@@ -192,8 +211,13 @@ export default defineNuxtPlugin(() => {
192
211
  // default open granted everything, gates that fail closed denied everything.
193
212
  // Awaited, so no gate is ever evaluated against an unresolved role.
194
213
  const site = idParam(to.params.site);
214
+ const routeOrg = idParam(to.params.org) || idParam(to.params.organization);
215
+ // Only an organisation's pages can be opened as staff, so only they ask.
216
+ const view = userId && routeOrg ? await bounded(loadStaffView()).catch(() => null) : null;
217
+ const staffOrg = staffViewCovers(view, routeOrg) ? routeOrg : "";
218
+ lastStaffOrg = staffOrg;
195
219
 
196
- if (userId) await resolveAppRole(userId, site);
220
+ if (userId) await resolveAppRole(userId, site, staffOrg);
197
221
  if (userId) await resolveDenied(userId, site);
198
222
  else if (userAppModulesDenied.value.length) {
199
223
  // Signed out: never carry the previous person's list.
@@ -268,7 +292,7 @@ export default defineNuxtPlugin(() => {
268
292
  const site = idParam(router.currentRoute.value.params.site);
269
293
 
270
294
  refreshing = (async () => {
271
- await run(userId, site);
295
+ await run(userId, site, lastStaffOrg);
272
296
  deniedFor = null;
273
297
  await resolveDenied(userId, site);
274
298
  })()
@@ -0,0 +1,10 @@
1
+ /** "Open as Seven365 staff": the client/site a staff session has open (core `staff-view.util.ts`). */
2
+ declare type TStaffView = {
3
+ user: string;
4
+ org: string;
5
+ orgName: string;
6
+ site: string;
7
+ siteName: string;
8
+ app: string;
9
+ startedAt: string;
10
+ };
@@ -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":