@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.
- package/components/Layout/Header.vue +2 -0
- package/components/MemberMain.vue +2 -1
- package/components/RolePermissionMain.vue +4 -2
- package/components/StaffViewBanner.vue +67 -0
- package/composables/useStaffView.ts +49 -0
- package/package.json +1 -1
- package/plugins/API.ts +16 -0
- package/plugins/secure-member.client.ts +31 -7
- package/types/staff-view.d.ts +10 -0
- package/utils/access-suspended.ts +18 -0
- package/utils/role.ts +15 -2
|
@@ -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="
|
|
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="
|
|
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.
|
|
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
|
-
|
|
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,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"}
|
|
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"}
|
|
72
|
+
return { text: site ? `${name || "Role"} · ${site}` : name || "Role", tone: "info" };
|
|
60
73
|
}
|
|
61
74
|
switch (item?.roleState) {
|
|
62
75
|
case "missing":
|