@7365admin1/layer-common 4.0.3-staging.235 → 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.
@@ -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.235",
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
  });