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

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
@@ -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.470",
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.",
@@ -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
+ };