@mohasinac/appkit 4.5.0 → 4.5.2

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,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import { type ReactNode } from "react";
2
2
  import type { LayoutRole } from "../../../shared/features/layout/types";
3
3
  export interface RoleGuardProps {
4
4
  /** Single role or array. Omit for auth-only (any signed-in user). */
@@ -12,13 +12,34 @@ import { jsx as _jsx } from "react/jsx-runtime";
12
12
  * route overrides. Everything else defaults to appkit's ROUTES.
13
13
  */
14
14
  import { useRouter } from "next/navigation";
15
+ import { useEffect, useRef, useState } from "react";
15
16
  import { ProtectedRoute } from "../../../../features/auth/components/Guards";
16
17
  import { useSession } from "../../../../react/contexts/SessionContext";
17
18
  import { ROUTES } from "../../../../next/routing/route-map";
18
19
  export function RoleGuard({ role, requireAuth = true, loginPath, unauthorizedPath, loadingComponent, children, }) {
19
- const { user, loading } = useSession();
20
+ const { user, loading, refreshUser } = useSession();
20
21
  const router = useRouter();
21
- return (_jsx(ProtectedRoute, { user: user, loading: loading, requireAuth: requireAuth, requireRole: role, onNavigate: (path) => router.push(path), routes: {
22
+ // SessionContext only refreshes role/disabled periodically (every 5
23
+ // minutes) or on a hard reload/re-login — never on ordinary client-side
24
+ // navigation. A user just approved as a seller, or just un-banned, would
25
+ // otherwise keep getting redirected to /unauthorized by this exact guard
26
+ // for up to 5 minutes even though the same check against live Firestore
27
+ // data would already pass. Force one fresh check per mount (i.e. once per
28
+ // navigation into a role-gated layout, since Next.js layouts persist
29
+ // across sibling route changes) before trusting a denial.
30
+ const hasRefreshedRef = useRef(false);
31
+ const [verifying, setVerifying] = useState(true);
32
+ useEffect(() => {
33
+ if (loading || hasRefreshedRef.current)
34
+ return;
35
+ hasRefreshedRef.current = true;
36
+ if (!user) {
37
+ setVerifying(false);
38
+ return;
39
+ }
40
+ refreshUser().finally(() => setVerifying(false));
41
+ }, [loading, user, refreshUser]);
42
+ return (_jsx(ProtectedRoute, { user: user, loading: loading || verifying, requireAuth: requireAuth, requireRole: role, onNavigate: (path) => router.push(path), routes: {
22
43
  loginPath: loginPath ?? String(ROUTES.AUTH.LOGIN),
23
44
  unauthorizedPath: unauthorizedPath ?? String(ROUTES.ERRORS.UNAUTHORIZED),
24
45
  }, loadingComponent: loadingComponent, children: children }));
@@ -7,7 +7,6 @@ import { apiClient } from "../../../http";
7
7
  import { useApiMutation } from "../../../client";
8
8
  import { useSession } from "../../../react";
9
9
  import { ACCOUNT_ENDPOINTS } from "../../../constants/api-endpoints";
10
- import { isAdminUser } from "../../auth/role-predicates";
11
10
  import { TesterChecklistStepRow } from "./TesterChecklistStepRow";
12
11
  function matchesQuery(item, query) {
13
12
  if (!query)
@@ -40,6 +39,11 @@ export function TesterHubView({ sandboxExpiresAt }) {
40
39
  const res = await apiClient.get(ACCOUNT_ENDPOINTS.TESTER_CHECKLIST);
41
40
  return res?.data ?? res;
42
41
  },
42
+ // The API route resolves isTester/canTestAdmin fresh from Firestore on
43
+ // every request, so retrying a 403 (a role that genuinely lacks access)
44
+ // wastes calls for no benefit — but a real access grant should still be
45
+ // visible on the very next request, not cached as a permanent failure.
46
+ retry: false,
43
47
  });
44
48
  const upsertMutation = useApiMutation({
45
49
  mutationFn: async (vars) => apiClient.put(ACCOUNT_ENDPOINTS.TESTER_CHECKLIST_ITEM_BY_ID(vars.checklistItemId), vars.patch),
@@ -47,9 +51,21 @@ export function TesterHubView({ sandboxExpiresAt }) {
47
51
  void queryClient.invalidateQueries({ queryKey: ["user", "tester-checklist"] });
48
52
  },
49
53
  });
50
- if (!user?.isTester && !isAdminUser(user)) {
54
+ // Gate on the live API response, not the client-cached session snapshot.
55
+ // SessionContext only refreshes role/isTester/canTestAdmin periodically
56
+ // (every 5 minutes) or on a hard reload/re-login — never on ordinary
57
+ // client-side navigation — so a flag an admin just granted would
58
+ // otherwise still read as "Testers only" here for up to 5 minutes even
59
+ // though the same request already succeeds server-side. The tester-
60
+ // checklist API resolves these fields fresh from Firestore on every call,
61
+ // so trusting its actual response is both more accurate and immediate.
62
+ const isForbidden = query.isError && query.error?.status === 403;
63
+ if (isForbidden) {
51
64
  return (_jsx(Alert, { variant: "warning", title: "Testers only", children: "This page is only available to accounts flagged as testers (and admins). Contact an admin if you believe this is a mistake." }));
52
65
  }
66
+ if (query.isLoading) {
67
+ return _jsx(Text, { color: "muted", children: "Loading checklist\u2026" });
68
+ }
53
69
  // Items arrive pre-sorted by catalog `order` (listActive() orders by it).
54
70
  const items = query.data?.items ?? [];
55
71
  const filtered = items.filter((item) => matchesQuery(item, search));
@@ -72,7 +88,7 @@ export function TesterHubView({ sandboxExpiresAt }) {
72
88
  const handleSaveNote = async (checklistItemId, comment, screenshotUrl) => {
73
89
  await upsertMutation.mutateAsync({ checklistItemId, patch: { comment, screenshotUrl } });
74
90
  };
75
- return (_jsxs(Stack, { gap: "lg", children: [_jsx(Heading, { level: 1, children: "Tester Hub" }), _jsx(Text, { color: "muted", children: "Work through the checklist below and answer Yes/No for each test case. Add a comment and screenshot wherever something looks off \u2014 colors, styles, readability, bugs. Your answers save automatically." }), sandboxExpiresAt && (_jsxs(Alert, { variant: "info", title: "Shared test sandbox", children: ["The shared test store/products/categories expire on ", new Date(sandboxExpiresAt).toLocaleDateString(), "."] })), _jsx(FieldInput, { name: "checklist-search", label: "Search test cases", value: search, onChange: setSearch, placeholder: "Search by title or route, e.g. checkout, /store/payouts..." }), query.isLoading && _jsx(Text, { color: "muted", children: "Loading checklist\u2026" }), sortedPhaseNumbers.map((phaseNumber) => {
91
+ return (_jsxs(Stack, { gap: "lg", children: [_jsx(Heading, { level: 1, children: "Tester Hub" }), _jsx(Text, { color: "muted", children: "Work through the checklist below and answer Yes/No for each test case. Add a comment and screenshot wherever something looks off \u2014 colors, styles, readability, bugs. Your answers save automatically." }), sandboxExpiresAt && (_jsxs(Alert, { variant: "info", title: "Shared test sandbox", children: ["The shared test store/products/categories expire on ", new Date(sandboxExpiresAt).toLocaleDateString(), "."] })), _jsx(FieldInput, { name: "checklist-search", label: "Search test cases", value: search, onChange: setSearch, placeholder: "Search by title or route, e.g. checkout, /store/payouts..." }), sortedPhaseNumbers.map((phaseNumber) => {
76
92
  const groups = phases.get(phaseNumber);
77
93
  const phaseItemCount = Array.from(groups.values())
78
94
  .flatMap((g) => Array.from(g.pages.values()))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.5.0",
3
+ "version": "4.5.2",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"