@mohasinac/appkit 4.35.2 → 4.35.3

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.
@@ -122,13 +122,28 @@ function useResponsiveDrawer(storageKey) {
122
122
  }
123
123
  /** Filter nav groups by navConfig (enabled toggle) + requiredPermission. */
124
124
  function filterGroups(groups, navConfig, permissions) {
125
- // null permissions = admin (show everything); undefined = no filtering (backwards compat)
126
- if (permissions === null && !navConfig)
125
+ /*
126
+ * `permissions === null` MEANS ADMIN, and that must hold whether or not a
127
+ * navConfig exists.
128
+ *
129
+ * The early return below encoded the convention correctly and then applied it
130
+ * only when `!navConfig`. The moment site settings carried ANY nav config,
131
+ * an admin fell through to `filterNavItems(..., undefined)` — and
132
+ * `undefined?.includes(x)` is falsy, so every item carrying a
133
+ * `requiredPermission` was dropped. The Admin Panel sidebar rendered empty
134
+ * while every admin page stayed reachable by URL.
135
+ *
136
+ * Passing `isAdmin` keeps the admin's navConfig toggles working (disabling an
137
+ * entry is a configuration decision, not a permission) while granting the
138
+ * permissions an admin implicitly holds.
139
+ */
140
+ const isAdmin = permissions === null;
141
+ if (isAdmin && !navConfig)
127
142
  return groups;
128
143
  return groups
129
144
  .map((group) => ({
130
145
  ...group,
131
- items: filterNavItems(group.items, navConfig, permissions ?? undefined),
146
+ items: filterNavItems(group.items, navConfig, permissions ?? undefined, isAdmin),
132
147
  }))
133
148
  .filter((group) => group.items.length > 0);
134
149
  }
@@ -8,8 +8,29 @@ type FilterableItem = {
8
8
  * 2. requiredPermission — item hidden when user lacks the permission
9
9
  *
10
10
  * Items without an `id` always pass through (legacy / unmanaged items).
11
+ *
12
+ * 🛑 A FULL ADMIN HOLDS EVERY PERMISSION IMPLICITLY — `permissions[]` REFINES
13
+ * EMPLOYEES, IT DOES NOT DEFINE ADMINS.
14
+ *
15
+ * That is how the rest of the system already works: every admin route guard is
16
+ * `roles: ROLES_ADMIN_ONLY`, and employees are the ones who carry explicit
17
+ * grants (the seeded employees hold 5 and 24 permissions respectively, while
18
+ * `user-admin-letitrip` holds NONE — matching its seed, so this is by design
19
+ * rather than drift). There is no role→permission derivation anywhere in the
20
+ * codebase for the nav to lean on.
21
+ *
22
+ * Without `isAdmin` here, an admin matched no `requiredPermission` and EVERY
23
+ * admin nav item was filtered out: the Admin Panel sidebar rendered completely
24
+ * empty, and searching it returned "No matches". Every admin page stayed
25
+ * reachable by typing its URL, which is exactly what made it look cosmetic.
26
+ *
27
+ * This surfaced only recently because the check had never run: `filterNavItems`
28
+ * opens with `if (!item.id) return true;` and no item carried an `id` until
29
+ * ids were derived (see navigation.tsx). Turning a dormant check on without the
30
+ * data to satisfy it is the same shape as seeding a ratchet from the wrong
31
+ * measurement — Root Cause #84.
11
32
  */
12
33
  export declare function filterNavItems<T extends FilterableItem>(items: T[], navConfig: Record<string, {
13
34
  enabled: boolean;
14
- }> | undefined, userPermissions: string[] | undefined): T[];
35
+ }> | undefined, userPermissions: string[] | undefined, isAdmin?: boolean): T[];
15
36
  export {};
@@ -4,13 +4,38 @@
4
4
  * 2. requiredPermission — item hidden when user lacks the permission
5
5
  *
6
6
  * Items without an `id` always pass through (legacy / unmanaged items).
7
+ *
8
+ * 🛑 A FULL ADMIN HOLDS EVERY PERMISSION IMPLICITLY — `permissions[]` REFINES
9
+ * EMPLOYEES, IT DOES NOT DEFINE ADMINS.
10
+ *
11
+ * That is how the rest of the system already works: every admin route guard is
12
+ * `roles: ROLES_ADMIN_ONLY`, and employees are the ones who carry explicit
13
+ * grants (the seeded employees hold 5 and 24 permissions respectively, while
14
+ * `user-admin-letitrip` holds NONE — matching its seed, so this is by design
15
+ * rather than drift). There is no role→permission derivation anywhere in the
16
+ * codebase for the nav to lean on.
17
+ *
18
+ * Without `isAdmin` here, an admin matched no `requiredPermission` and EVERY
19
+ * admin nav item was filtered out: the Admin Panel sidebar rendered completely
20
+ * empty, and searching it returned "No matches". Every admin page stayed
21
+ * reachable by typing its URL, which is exactly what made it look cosmetic.
22
+ *
23
+ * This surfaced only recently because the check had never run: `filterNavItems`
24
+ * opens with `if (!item.id) return true;` and no item carried an `id` until
25
+ * ids were derived (see navigation.tsx). Turning a dormant check on without the
26
+ * data to satisfy it is the same shape as seeding a ratchet from the wrong
27
+ * measurement — Root Cause #84.
7
28
  */
8
- export function filterNavItems(items, navConfig, userPermissions) {
29
+ export function filterNavItems(items, navConfig, userPermissions, isAdmin = false) {
9
30
  return items.filter((item) => {
10
31
  if (!item.id)
11
32
  return true;
33
+ // The admin toggle still applies to admins — disabling a nav entry is a
34
+ // deliberate configuration choice, not a permission the admin outranks.
12
35
  if (!(navConfig?.[item.id]?.enabled ?? true))
13
36
  return false;
37
+ if (isAdmin)
38
+ return true;
14
39
  if (item.requiredPermission && !userPermissions?.includes(item.requiredPermission))
15
40
  return false;
16
41
  return true;
@@ -49,5 +49,5 @@ interface GuardInput {
49
49
  * derive the SEO filename. Mirrors the inline logic that used to live
50
50
  * in /api/media/upload.
51
51
  */
52
- export declare function applyMediaContextGuards({ detectedMime, context: ctx, }: GuardInput): GuardResult;
52
+ export declare function applyMediaContextGuards(input: GuardInput): GuardResult;
53
53
  export {};
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { classifyMime, MIME_TO_EXT, PRODUCT_IMAGE_INDEX_MAX, PRODUCT_MAX_VIDEOS, } from "../../../shared/media/limits";
13
13
  import { generateMediaFilename, validateMediaFilename, } from "../../../../utils/id-generators";
14
+ import { normalizeError } from "../../../../errors/normalize";
14
15
  /**
15
16
  * W1-51 — validate the generated filename before returning ok:true so a
16
17
  * malformed name can never enter signed-URL storage. Generators producing
@@ -89,7 +90,39 @@ function indexGuard(receivedIndex, max, errorMessage) {
89
90
  * derive the SEO filename. Mirrors the inline logic that used to live
90
91
  * in /api/media/upload.
91
92
  */
92
- export function applyMediaContextGuards({ detectedMime, context: ctx, }) {
93
+ /*
94
+ * 🛑 A MISSING CONTEXT FIELD IS A 400, NEVER A 500.
95
+ *
96
+ * The filename generators slugify their inputs, and `slugify(undefined)` throws
97
+ * `Cannot read properties of undefined (reading 'toLowerCase')` from deep inside
98
+ * `id-generators`. That escaped `applyMediaContextGuards` as an unhandled throw,
99
+ * so `/api/media/sign` answered 500 INTERNAL — twice in production — for what is
100
+ * simply a caller that forgot a field. The guards below validate `index` but
101
+ * have never validated the string fields, and enumerating every generator's
102
+ * requirements here would drift from the generators themselves (Root Cause #61).
103
+ *
104
+ * So the boundary catches instead: whatever the generator needs, failing to
105
+ * supply it now returns an actionable 400 naming the context type, and the real
106
+ * message is logged rather than shown. The caller-side fix still matters — this
107
+ * only guarantees the failure mode is honest.
108
+ */
109
+ export function applyMediaContextGuards(input) {
110
+ try {
111
+ return applyMediaContextGuardsInner(input);
112
+ }
113
+ catch (err) {
114
+ const normalized = normalizeError(err);
115
+ const message = normalized.message;
116
+ return {
117
+ ok: false,
118
+ status: 400,
119
+ error: `The upload context for "${input.context?.type ?? "unknown"}" is missing a required field. ` +
120
+ `Check every field its filename pattern needs.`,
121
+ details: { context: input.context?.type, thrown: message },
122
+ };
123
+ }
124
+ }
125
+ function applyMediaContextGuardsInner({ detectedMime, context: ctx, }) {
93
126
  const kind = classifyMime(detectedMime);
94
127
  if (!kind) {
95
128
  return {
@@ -2,13 +2,32 @@
2
2
  import { jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Text } from "../../../ui/components/Typography";
4
4
  import { safeDisplayName } from "../../../security/pii-redact";
5
+ /**
6
+ * `store-beyblade-arena` → `Beyblade Arena`.
7
+ *
8
+ * The slug fallback was described as "readable", and next to a raw Firestore id
9
+ * it is — but a buyer reading "by store-beyblade-arena" under a product does not
10
+ * see a readable name, they see a leaked identifier. Pre-order and auction
11
+ * documents do not denormalize `storeName`, so this fallback is what a large
12
+ * share of cards actually render, on every listing page.
13
+ *
14
+ * Humanising here fixes all six card variants at once and needs no data change.
15
+ * Denormalising `storeName` onto those documents is the better long-term fix and
16
+ * would simply stop this path being reached.
17
+ */
18
+ function humanizeStoreSlug(slug) {
19
+ return slug
20
+ .replace(/^store-/, "")
21
+ .split("-")
22
+ .filter(Boolean)
23
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
24
+ .join(" ");
25
+ }
5
26
  export function CardStoreLine({ storeName, storeId, className }) {
6
27
  if (!storeId && !storeName)
7
28
  return null;
8
29
  // `safeDisplayName` also guards the encrypted-value case: a PII-ciphertext
9
30
  // string reaching a card must render as a label, never as `enc:v1:…`.
10
- // `storeId` IS the store slug in this codebase, so it is a readable last
11
- // resort rather than an opaque key.
12
- const seller = safeDisplayName(storeName, storeId ?? "Seller");
31
+ const seller = safeDisplayName(storeName, storeId ? humanizeStoreSlug(storeId) : "Seller");
13
32
  return (_jsxs(Text, { className: className ?? "mt-0.5 text-[11px]", color: "faint", children: ["by ", seller] }));
14
33
  }
@@ -147,6 +147,13 @@ function StepMedia({ values, onChange, storeSlug = "store", }) {
147
147
  }), onChangeField: (media) => onChange({ video: media ?? undefined }), onThumbnailChange: (thumbnailUrl) => onChange({ video: values.video ? { ...values.video, thumbnailUrl } : undefined }), onDurationChange: (duration) => onChange({ video: values.video ? { ...values.video, duration } : undefined }), onUpload: (file) => upload(file, "products", true, {
148
148
  type: "product-video",
149
149
  name: values.title ?? "product",
150
+ // `category` is REQUIRED — generateProductVideoFilename slugifies it,
151
+ // and slugify(undefined) threw `Cannot read properties of undefined
152
+ // (reading 'toLowerCase')` inside the generator, surfacing as a 500
153
+ // from /api/media/sign. Every product video upload from this panel
154
+ // failed that way; ProductForm's gallery path passed it and worked,
155
+ // which is why only the dedicated video tab was broken.
156
+ category: values.category || "uncategorized",
150
157
  store: storeSlug,
151
158
  }), kind: "auto", helperText: "The gallery above accepts a video file directly. Use this panel for a YouTube or external URL, or to trim and pick a poster frame." }), _jsx(FormField, { name: "externalVideoUrl", label: "3rd-party video URL (YouTube / Vimeo)", type: "text", value: values.externalVideoUrl ?? "", onChange: (v) => onChange({ externalVideoUrl: v }), placeholder: "https://www.youtube.com/watch?v=\u2026", hint: "External video links are queued for moderation and become visible to buyers after admin approval." }), _jsx(FormField, { name: "youtubeId", label: "YouTube Video ID (optional)", type: "text", value: values.youtubeId ?? "", onChange: (v) => onChange({ youtubeId: v }), placeholder: "e.g. dQw4w9WgXcQ", helpText: "Paste the 11-character ID from the YouTube URL" })] }));
152
159
  }
@@ -95,14 +95,14 @@ export const authored = {
95
95
  startPage: "/products",
96
96
  steps: [
97
97
  "Open the network panel and navigate to a product path whose trailing segment is a nonsense word no listing uses.",
98
- "Read the status code of the document response.",
99
98
  "Read what the page shows.",
100
- "View the source and look for a robots meta tag.",
101
- "Repeat with a nonsense store path and a nonsense category path.",
99
+ "View the source and read every robots meta tag.",
100
+ "Repeat with a nonsense store path and a nonsense auction path.",
101
+ "Open a REAL product page and read its robots meta tags too.",
102
102
  ],
103
- expectedBehaviour: "A missing page returns 404 and marks itself not-indexable. A 200 carrying a 'not found' message is a soft 404 — the URL stays in the index indefinitely, and every mistyped link a crawler follows adds another.",
104
- expectedUiState: "The document response is 404 on all three paths, the page shows a readable not-found state with a route onward, and it is marked not-indexable. A 200 status is the finding regardless of what the body says.",
105
- expectedData: { statusCode: 404 },
103
+ expectedBehaviour: "A missing page renders the site's 404 view and marks itself NOINDEX. Judge the noindex, not the status code: Next streams the response, so the headers are already sent by the time the not-found is raised and the status can no longer be changed. It injects a noindex robots tag into the streamed HTML instead, and that is what actually keeps the URL out of the index. A 200 here is expected and is not the finding.",
104
+ expectedUiState: "All three missing paths render the 404 view and their source carries a robots tag whose content is noindex. The real product page carries no noindex — check it, because a blanket noindex would satisfy the first half while quietly de-indexing the whole catalogue.",
105
+ expectedData: { missingPageIsNoindex: true, realPageIsNoindex: false },
106
106
  endResult: "Read-only.",
107
107
  },
108
108
  "checklist-seo-page-metadata-title-length-reasonable": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.35.2",
3
+ "version": "4.35.3",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"