@7365admin1/layer-common 4.0.3-staging.234 → 4.0.3-staging.236

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.
@@ -92,6 +92,16 @@
92
92
  font-family: var(--font-sans, Roboto, sans-serif);
93
93
  }
94
94
 
95
+ /* `[class*="text-h"]` is a substring match, so the rule above would also
96
+ catch an icon: `text-h6` and friends are used as SIZE classes directly on
97
+ `<v-icon>` (DeliveryCompany, PasswordConfirmation, OvernightParking-
98
+ Management), and `text-high-emphasis` matches too. The re-pointed family
99
+ would then beat `.material-symbols-outlined`, the ligature would fall back,
100
+ and the icon would draw as its literal name - hygiene saw the red word
101
+ "error" on its work-order list. `:not(.material-symbols-outlined)` keeps
102
+ icons out of the match set; web-app-hygiene `517eaeb` and web-app-account
103
+ #26 carry the same guard on their local copies. */
104
+
95
105
  /* ------------------------------------------------------------------ */
96
106
  /* The icon face. */
97
107
  /* ------------------------------------------------------------------ */
@@ -109,8 +119,18 @@
109
119
  * through `.mdi:before`, which we no longer emit. Only `text-transform` has
110
120
  * to be stated: `.v-btn` uppercases its contents, and an uppercased "close"
111
121
  * matches no ligature, so the icon would render as the word.
122
+ *
123
+ * The scoped selectors are load-bearing, not belt-and-braces: an app bundle
124
+ * emits Vuetify's typography utilities twice, and the second
125
+ * `.text-h6 { font-family: Roboto }` lands AFTER this rule, winning the
126
+ * 0,1,0 tie on source order. An icon sized with `text-h6` then drew the
127
+ * word in Roboto even with the re-point above guarded (measured in
128
+ * web-app-account's bundle). `.v-application`-scoped, the family is 0,2,0
129
+ * and survives any typography size class on the icon.
112
130
  */
113
- .material-symbols-outlined {
131
+ .material-symbols-outlined,
132
+ .v-application .material-symbols-outlined,
133
+ .v-overlay-container .material-symbols-outlined {
114
134
  font-family: "Material Symbols Outlined";
115
135
  font-style: normal;
116
136
  font-variation-settings:
@@ -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.234",
5
+ "version": "4.0.3-staging.236",
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
  });