@mohasinac/appkit 4.29.0 → 4.30.0
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/dist/_internal/client/features/layout/SidebarCollapseToggle.js +1 -1
- package/dist/_internal/server/jobs/handlers/_helpers.d.ts +22 -0
- package/dist/_internal/server/jobs/handlers/_helpers.js +25 -1
- package/dist/_internal/shared/features/blog/schema.d.ts +2 -2
- package/dist/_internal/shared/features/blog/schema.js +2 -1
- package/dist/_internal/shared/features/brands/schema.d.ts +2 -2
- package/dist/_internal/shared/features/brands/schema.js +3 -1
- package/dist/_internal/shared/features/categories/bundle-schemas.d.ts +3 -3
- package/dist/_internal/shared/features/categories/bundle-schemas.js +2 -1
- package/dist/_internal/shared/features/events/schema.d.ts +2 -2
- package/dist/_internal/shared/features/events/schema.js +2 -1
- package/dist/_internal/shared/features/products/schema.d.ts +8 -8
- package/dist/_internal/shared/features/products/schema.js +3 -2
- package/dist/_internal/shared/features/reviews/schema.d.ts +1 -1
- package/dist/_internal/shared/features/reviews/schema.js +2 -1
- package/dist/_internal/shared/types/action-result.d.ts +0 -2
- package/dist/client/api/surface-error.d.ts +9 -0
- package/dist/client/api/surface-error.js +29 -3
- package/dist/client/api/useApiMutation.d.ts +10 -0
- package/dist/client/api/useApiMutation.js +2 -1
- package/dist/core/server-action.d.ts +3 -14
- package/dist/core/server-action.js +11 -2
- package/dist/errors/error-envelope.d.ts +73 -0
- package/dist/errors/error-envelope.js +96 -0
- package/dist/errors/error-handler.js +14 -7
- package/dist/features/account/components/NotificationBell.js +1 -1
- package/dist/features/account/components/UserSidebar.js +2 -2
- package/dist/features/account/components/UserSupportView.js +2 -6
- package/dist/features/account/hooks/useProfile.d.ts +6 -0
- package/dist/features/account/hooks/useProfile.js +1 -0
- package/dist/features/admin/components/AdminAdEditorView.js +1 -3
- package/dist/features/admin/components/AdminAddressClustersView.js +1 -3
- package/dist/features/admin/components/AdminAddressEditorView.js +2 -4
- package/dist/features/admin/components/AdminAddressesView.js +1 -3
- package/dist/features/admin/components/AdminAllEventEntriesView.js +1 -3
- package/dist/features/admin/components/AdminBidsView.js +1 -3
- package/dist/features/admin/components/AdminBlogEditorView.js +2 -2
- package/dist/features/admin/components/AdminBrandEditorView.js +4 -5
- package/dist/features/admin/components/AdminCarouselGroupEditorView.js +1 -3
- package/dist/features/admin/components/AdminCategoryEditorView.js +2 -4
- package/dist/features/admin/components/AdminContactEditorView.js +1 -3
- package/dist/features/admin/components/AdminContactView.js +2 -6
- package/dist/features/admin/components/AdminCouponEditorView.js +2 -4
- package/dist/features/admin/components/AdminEmployeeEditorView.js +3 -9
- package/dist/features/admin/components/AdminFaqEditorView.js +2 -4
- package/dist/features/admin/components/AdminFeatureEditorView.js +0 -9
- package/dist/features/admin/components/AdminFeatureFlagsView.js +2 -4
- package/dist/features/admin/components/AdminNavEditorView.js +1 -3
- package/dist/features/admin/components/AdminNavigationView.js +3 -3
- package/dist/features/admin/components/AdminNewsletterView.js +1 -3
- package/dist/features/admin/components/AdminNotificationsView.js +2 -6
- package/dist/features/admin/components/AdminOffersView.js +1 -3
- package/dist/features/admin/components/AdminOrderEditorView.js +1 -3
- package/dist/features/admin/components/AdminPaymentClustersView.js +1 -3
- package/dist/features/admin/components/AdminPaymentMethodsView.js +1 -3
- package/dist/features/admin/components/AdminPayoutMarkPaidModal.js +2 -4
- package/dist/features/admin/components/AdminProductEditorView.js +2 -4
- package/dist/features/admin/components/AdminReturnRequestsView.js +2 -6
- package/dist/features/admin/components/AdminReviewsView.js +2 -6
- package/dist/features/admin/components/AdminScammerEditorView.js +2 -6
- package/dist/features/admin/components/AdminSectionsView.js +2 -6
- package/dist/features/admin/components/AdminSessionsView.js +1 -3
- package/dist/features/admin/components/AdminSidebar.js +2 -2
- package/dist/features/admin/components/AdminSiteSettingsView.js +1 -1
- package/dist/features/admin/components/AdminStoreEditorView.js +1 -3
- package/dist/features/admin/components/AdminStoresView.js +2 -6
- package/dist/features/admin/components/AdminSublistingCategoryEditorView.js +2 -6
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +7 -21
- package/dist/features/admin/components/AdminTesterChecklistItemEditorView.js +2 -4
- package/dist/features/admin/components/AdminUserEditorView.js +6 -18
- package/dist/features/admin/components/AdminUsersView.js +2 -6
- package/dist/features/admin/components/BrandQuickCreateForm.js +2 -4
- package/dist/features/admin/components/CategoryQuickCreateForm.js +2 -4
- package/dist/features/auctions/components/PlaceBidFormClient.js +1 -1
- package/dist/features/auth/components/ForgotPasswordView.js +1 -1
- package/dist/features/auth/components/LoginForm.js +1 -1
- package/dist/features/auth/components/RegisterForm.js +1 -1
- package/dist/features/auth/components/ResetPasswordView.js +1 -1
- package/dist/features/auth/components/SocialAuthButtons.js +1 -1
- package/dist/features/auth/repository/session.repository.js +13 -1
- package/dist/features/blog/actions/blog-actions.d.ts +14 -14
- package/dist/features/blog/actions/blog-actions.js +6 -8
- package/dist/features/blog/schemas/index.d.ts +1 -1
- package/dist/features/blog/schemas/index.js +2 -4
- package/dist/features/categories/api/[id]/route.js +2 -1
- package/dist/features/categories/api/route.js +2 -1
- package/dist/features/events/schemas/index.d.ts +1 -1
- package/dist/features/events/schemas/index.js +2 -4
- package/dist/features/faq/components/ContactCTA.js +1 -1
- package/dist/features/filters/AsyncFacetSection.js +1 -1
- package/dist/features/filters/FilterFacetSection.js +1 -1
- package/dist/features/filters/RangeFilter.js +1 -1
- package/dist/features/filters/SwitchFilter.js +1 -1
- package/dist/features/homepage/api/carousel/[id]/route.js +4 -3
- package/dist/features/homepage/api/carousel/route.js +4 -3
- package/dist/features/homepage/api/route.js +3 -2
- package/dist/features/homepage/components/GoogleReviewsSection.js +1 -1
- package/dist/features/homepage/components/HeroBanner.js +1 -1
- package/dist/features/homepage/components/HeroCarousel.js +1 -1
- package/dist/features/homepage/components/SocialPostCard.js +1 -1
- package/dist/features/homepage/components/WhatsAppCommunitySection.js +1 -1
- package/dist/features/layout/AppLayoutShell.js +4 -4
- package/dist/features/layout/BottomNavbar.js +2 -2
- package/dist/features/layout/MainNavbar.js +1 -1
- package/dist/features/layout/TitleBarLayout.js +24 -12
- package/dist/features/media/AvatarUpload.js +28 -10
- package/dist/features/media/modals/ImageCropModal.js +1 -1
- package/dist/features/media/modals/ImageEditor.js +1 -1
- package/dist/features/products/components/MakeOfferButton.js +1 -1
- package/dist/features/reviews/api/[id]/route.js +3 -2
- package/dist/features/reviews/api/route.js +3 -2
- package/dist/features/search/components/Search.js +2 -2
- package/dist/features/seller/components/SellerAnalyticsAlertsView.js +3 -3
- package/dist/features/seller/components/SellerSidebar.js +2 -2
- package/dist/features/store-extensions/components/ReviewDecisionModal.js +1 -1
- package/dist/features/stores/repository/store.repository.d.ts +21 -0
- package/dist/features/stores/repository/store.repository.js +32 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -1
- package/dist/next/api/api-response.d.ts +29 -12
- package/dist/next/api/api-response.js +9 -8
- package/dist/next/api/apiHandler.d.ts +6 -1
- package/dist/next/api/apiHandler.js +13 -1
- package/dist/next/api/routeHandler.js +6 -6
- package/dist/next/middleware/pii-scrubber.js +5 -2
- package/dist/providers/db-firebase/base.repository.d.ts +14 -0
- package/dist/providers/db-firebase/base.repository.js +22 -0
- package/dist/providers/db-firebase/sieve.d.ts +14 -1
- package/dist/providers/db-firebase/sieve.js +3 -1
- package/dist/security/pii-encrypt.d.ts +0 -4
- package/dist/security/pii-encrypt.js +79 -13
- package/dist/security/pii-mask.d.ts +39 -1
- package/dist/security/pii-mask.js +33 -4
- package/dist/security/pii-redact.d.ts +0 -13
- package/dist/security/pii-redact.js +3 -1
- package/dist/security/pii-schemas.d.ts +0 -2
- package/dist/security/pii-schemas.js +16 -2
- package/dist/security/settings-encryption.js +31 -3
- package/dist/seed/stores-seed-data.js +18 -0
- package/dist/seed/test-utils.js +6 -2
- package/dist/ui/DataTable.js +2 -2
- package/dist/ui/components/CollapsibleSection.js +1 -1
- package/dist/ui/components/ImageGallery.js +1 -1
- package/dist/ui/components/ListingLayout.js +1 -1
- package/dist/ui/forms/FormShell.d.ts +0 -19
- package/dist/ui/forms/FormShell.js +4 -23
- package/dist/ui/forms/apply-zod-issues.d.ts +34 -0
- package/dist/ui/forms/apply-zod-issues.js +38 -0
- package/dist/ui/forms/index.d.ts +2 -1
- package/dist/ui/forms/index.js +2 -1
- package/dist/ui/icons/icon-registry.d.ts +8 -0
- package/dist/ui/icons/icon-registry.js +13 -1
- package/dist/utils/action-response.d.ts +1 -1
- package/dist/utils/media-url.d.ts +49 -0
- package/dist/utils/media-url.js +72 -1
- package/dist/validation/schemas.d.ts +15 -2
- package/dist/validation/schemas.js +18 -22
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +10 -2
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* Ciphertext format: "enc:v1:<iv_b64>:<ciphertext_b64>:<authtag_b64>"
|
|
10
10
|
* Blind index format: "hmac-sha256:<sha256_hex>"
|
|
11
11
|
*/
|
|
12
|
-
import { ENC_PREFIX, HMAC_PREFIX, isPiiEncrypted } from "./pii-mask";
|
|
12
|
+
import { ENC_PREFIX, HMAC_PREFIX, isPiiEncrypted, encEnvelopeKind } from "./pii-mask";
|
|
13
|
+
import { serverLogger } from "../monitoring/server-logger";
|
|
14
|
+
import { normalizeError } from "../errors/normalize";
|
|
13
15
|
// Re-exported for backward-compat with existing direct imports of these
|
|
14
16
|
// symbols from this file — the actual crypto-free definitions live in
|
|
15
17
|
// pii-mask.ts (see that file's header comment for why the split exists).
|
|
@@ -50,10 +52,32 @@ function getEncKey() {
|
|
|
50
52
|
const hex = normalizePiiSecretValue(process.env.PII_ENCRYPTION_KEY);
|
|
51
53
|
return Buffer.from(hex, "hex");
|
|
52
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Blind-index key. Normalised and validated exactly like `getEncKey`.
|
|
57
|
+
*
|
|
58
|
+
* It previously read the env var RAW while `getEncKey` normalised the same
|
|
59
|
+
* value — so the two keys were derived from one variable by two different code
|
|
60
|
+
* paths. `Buffer.from(x, "hex")` does not throw on bad input; it truncates
|
|
61
|
+
* silently at the first non-hex byte. A BOM or trailing CR (a problem this repo
|
|
62
|
+
* has hit before — see scripts/_fix-vercel-env-bom.mjs) would therefore give
|
|
63
|
+
* the encryptor a 32-byte key and the indexer a 0-byte one, and every blind
|
|
64
|
+
* index written from that moment would be unmatchable against the existing
|
|
65
|
+
* ones. `findByEmail` would simply stop finding people — indistinguishable
|
|
66
|
+
* from "no such user", with nothing logged.
|
|
67
|
+
*
|
|
68
|
+
* Verified before changing: on the current environment the raw and normalised
|
|
69
|
+
* reads produce byte-identical keys, so this is a no-op for every index
|
|
70
|
+
* already in Firestore.
|
|
71
|
+
*/
|
|
53
72
|
function getHmacKey() {
|
|
54
|
-
const raw = process.env["PII_HMAC_KEY"] ?? process.env["PII_ENCRYPTION_KEY"] ?? "";
|
|
73
|
+
const raw = normalizePiiSecretValue(process.env["PII_HMAC_KEY"] ?? process.env["PII_ENCRYPTION_KEY"] ?? "");
|
|
55
74
|
if (!raw)
|
|
56
75
|
throw new Error("PII_HMAC_KEY env var is not set — cannot generate blind PII index");
|
|
76
|
+
if (!/^[0-9a-fA-F]{64}$/.test(raw))
|
|
77
|
+
// Fail loudly at the boundary. A startup error is recoverable; a silently
|
|
78
|
+
// truncated key is not.
|
|
79
|
+
throw new Error("PII_HMAC_KEY (or its PII_ENCRYPTION_KEY fallback) must be a 64-character hex string — " +
|
|
80
|
+
`got ${raw.length} chars. Buffer.from would truncate this silently and desync every blind index.`);
|
|
57
81
|
return Buffer.from(raw, "hex");
|
|
58
82
|
}
|
|
59
83
|
/** Encrypt a plaintext string with AES-256-GCM. */
|
|
@@ -70,10 +94,19 @@ export function encryptValue(plaintext) {
|
|
|
70
94
|
export function decryptValue(ciphertext) {
|
|
71
95
|
if (!ciphertext.startsWith(ENC_PREFIX))
|
|
72
96
|
return ciphertext;
|
|
97
|
+
// Name the actual problem. A settings-encrypted value shares this prefix but
|
|
98
|
+
// uses dot separators and a different key; it used to fail here as a bare
|
|
99
|
+
// part-count error, which reads like corruption rather than "wrong system".
|
|
100
|
+
if (encEnvelopeKind(ciphertext) === "settings") {
|
|
101
|
+
throw new Error("Value is settings-encrypted (SETTINGS_ENCRYPTION_KEY, dot-separated), not PII-encrypted — decryptValue cannot read it");
|
|
102
|
+
}
|
|
73
103
|
const inner = ciphertext.slice(ENC_PREFIX.length);
|
|
74
104
|
const parts = inner.split(":");
|
|
75
105
|
if (parts.length !== 3)
|
|
76
|
-
|
|
106
|
+
// NEVER interpolate the value. This used to embed the whole ciphertext,
|
|
107
|
+
// which then flowed into DatabaseError and serverLogger.error — encrypted
|
|
108
|
+
// PII sitting in the logs on every malformed row. Shape only.
|
|
109
|
+
throw new Error(`Invalid PII ciphertext: expected 3 colon-separated parts after ${ENC_PREFIX}, got ${parts.length} (${ciphertext.length} chars)`);
|
|
77
110
|
const [ivB64, encB64, tagB64] = parts;
|
|
78
111
|
const { createDecipheriv } = nodeCrypto();
|
|
79
112
|
const key = getEncKey();
|
|
@@ -124,14 +157,20 @@ function setPath(obj, path, value) {
|
|
|
124
157
|
/**
|
|
125
158
|
* Encrypt the named fields, adding a blind index alongside each top-level one.
|
|
126
159
|
*
|
|
127
|
-
* Supports dotted paths (`deviceInfo.ip`, `
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
160
|
+
* Supports dotted paths (`deviceInfo.ip`, `location.city`). This was a flat
|
|
161
|
+
* top-level loop that skipped anything non-string, which is why nested PII
|
|
162
|
+
* stayed in plaintext.
|
|
163
|
+
*
|
|
164
|
+
* 🛑 That dotted-path support is also what made it possible to point this
|
|
165
|
+
* function at a field belonging to the OTHER crypto system. A store's
|
|
166
|
+
* `whatsappConfig.accessToken` is encrypted with SETTINGS_ENCRYPTION_KEY;
|
|
167
|
+
* passing it here would encrypt it with the PII key while
|
|
168
|
+
* `StoreRepository.decryptSecrets` kept decrypting with the settings one.
|
|
169
|
+
* Only ever pass fields from this file's own `*_PII_FIELDS` lists.
|
|
132
170
|
*
|
|
133
|
-
* Idempotent: a value already carrying `enc:v1:` is left alone
|
|
134
|
-
* over a partially-migrated document is safe
|
|
171
|
+
* Idempotent: a value already carrying `enc:v1:` is left alone — whichever
|
|
172
|
+
* system wrote it — so re-running over a partially-migrated document is safe
|
|
173
|
+
* and a settings-encrypted value is never double-wrapped.
|
|
135
174
|
*/
|
|
136
175
|
/**
|
|
137
176
|
* Encrypt `itemPath` on every element of the array at `arrayPath`.
|
|
@@ -192,6 +231,33 @@ export function encryptPiiFields(doc, piiFields) {
|
|
|
192
231
|
* Decrypt specified PII fields in a Firestore document.
|
|
193
232
|
* Fields that are not encrypted (no `enc:v1:` prefix) are passed through.
|
|
194
233
|
*/
|
|
234
|
+
/**
|
|
235
|
+
* Decrypt one value, or return a sentinel and warn.
|
|
236
|
+
*
|
|
237
|
+
* A single corrupt or foreign-format row must not take out the page. Before
|
|
238
|
+
* the sieve read path started calling `mapDoc` (which is what decrypts),
|
|
239
|
+
* a bad value was merely rendered as ciphertext — cosmetic. Once decryption
|
|
240
|
+
* actually runs on list reads, an unguarded throw inside a 100-row admin page
|
|
241
|
+
* turns that cosmetic defect into a 500 for the whole list.
|
|
242
|
+
*
|
|
243
|
+
* Same fail-soft posture `SiteSettingsRepository.getDecryptedCredentials`
|
|
244
|
+
* already takes for the settings key.
|
|
245
|
+
*
|
|
246
|
+
* The warning names the FIELD, never the value.
|
|
247
|
+
*/
|
|
248
|
+
function decryptOrWarn(value, field) {
|
|
249
|
+
try {
|
|
250
|
+
return decryptValue(value);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
const normalized = normalizeError(err);
|
|
254
|
+
serverLogger.warn("PII decrypt failed — field left empty", {
|
|
255
|
+
field,
|
|
256
|
+
reason: normalized.message,
|
|
257
|
+
});
|
|
258
|
+
return "";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
195
261
|
export function decryptPiiFields(doc, piiFields) {
|
|
196
262
|
const result = { ...doc };
|
|
197
263
|
for (const field of piiFields) {
|
|
@@ -211,7 +277,7 @@ export function decryptPiiFields(doc, piiFields) {
|
|
|
211
277
|
const clone = { ...el };
|
|
212
278
|
const value = itemPath ? getPath(clone, itemPath) : undefined;
|
|
213
279
|
if (typeof value === "string" && value.startsWith(ENC_PREFIX)) {
|
|
214
|
-
setPath(clone, itemPath,
|
|
280
|
+
setPath(clone, itemPath, decryptOrWarn(value, field));
|
|
215
281
|
}
|
|
216
282
|
return clone;
|
|
217
283
|
}));
|
|
@@ -220,13 +286,13 @@ export function decryptPiiFields(doc, piiFields) {
|
|
|
220
286
|
if (field.includes(".")) {
|
|
221
287
|
const value = getPath(result, field);
|
|
222
288
|
if (typeof value === "string" && value.startsWith(ENC_PREFIX)) {
|
|
223
|
-
setPath(result, field,
|
|
289
|
+
setPath(result, field, decryptOrWarn(value, field));
|
|
224
290
|
}
|
|
225
291
|
continue;
|
|
226
292
|
}
|
|
227
293
|
const value = result[field];
|
|
228
294
|
if (typeof value === "string" && value.startsWith(ENC_PREFIX)) {
|
|
229
|
-
result[field] =
|
|
295
|
+
result[field] = decryptOrWarn(value, field);
|
|
230
296
|
}
|
|
231
297
|
}
|
|
232
298
|
return result;
|
|
@@ -14,7 +14,45 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export declare const ENC_PREFIX = "enc:v1:";
|
|
16
16
|
export declare const HMAC_PREFIX = "hmac-sha256:";
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Which crypto system produced an `enc:v1:` value.
|
|
19
|
+
*
|
|
20
|
+
* TWO systems share this prefix and always have:
|
|
21
|
+
*
|
|
22
|
+
* PII `enc:v1:` + iv `:` ciphertext `:` tag — PII_ENCRYPTION_KEY
|
|
23
|
+
* settings `enc:v1:` + iv `.` tag `.` ciphertext — SETTINGS_ENCRYPTION_KEY
|
|
24
|
+
*
|
|
25
|
+
* Different separator, different field order, different key. `isPiiEncrypted`
|
|
26
|
+
* checked only the prefix and so could not tell them apart, which made every
|
|
27
|
+
* "is this ours" test wrong for one of the two — feed a settings value to
|
|
28
|
+
* `decryptValue` and it throws on the part count; feed a PII value to
|
|
29
|
+
* `decryptSecret` and it throws on a different part count. Neither error names
|
|
30
|
+
* the actual problem.
|
|
31
|
+
*
|
|
32
|
+
* Changing either prefix is NOT an option: `enc:v1:` is stamped into every
|
|
33
|
+
* encrypted value across twelve collections, and a new prefix orphans all of
|
|
34
|
+
* it. It does not need changing. Base64's alphabet is `A-Za-z0-9+/=`, which
|
|
35
|
+
* contains neither `.` nor `:` — so the two formats are ALREADY unambiguous in
|
|
36
|
+
* the data, and this function simply reads what is there. No migration.
|
|
37
|
+
*/
|
|
38
|
+
export type EncEnvelopeKind = "pii" | "settings" | null;
|
|
39
|
+
export declare function encEnvelopeKind(value: unknown): EncEnvelopeKind;
|
|
40
|
+
/**
|
|
41
|
+
* Does this value carry the `enc:v1:` prefix at all, whichever system wrote it
|
|
42
|
+
* and even if malformed?
|
|
43
|
+
*
|
|
44
|
+
* This is the check the DISPLAY masks want. `maskName`/`maskEmail` exist so
|
|
45
|
+
* ciphertext never reaches the UI, and for that purpose "settings-encrypted"
|
|
46
|
+
* and "malformed" are just as unshowable as "PII-encrypted". Narrowing them to
|
|
47
|
+
* `kind === "pii"` would let the other two render verbatim.
|
|
48
|
+
*/
|
|
49
|
+
export declare function hasEncPrefix(value: unknown): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Check if a value is encrypted by our PII system SPECIFICALLY.
|
|
52
|
+
*
|
|
53
|
+
* Use for crypto routing — "may I hand this to decryptValue". For "is this
|
|
54
|
+
* unshowable", use `hasEncPrefix`.
|
|
55
|
+
*/
|
|
18
56
|
export declare function isPiiEncrypted(value: string): boolean;
|
|
19
57
|
/**
|
|
20
58
|
* Mask a person's name for public display.
|
|
@@ -14,10 +14,39 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export const ENC_PREFIX = "enc:v1:";
|
|
16
16
|
export const HMAC_PREFIX = "hmac-sha256:";
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
export function encEnvelopeKind(value) {
|
|
18
|
+
if (typeof value !== "string" || !value.startsWith(ENC_PREFIX))
|
|
19
|
+
return null;
|
|
20
|
+
const inner = value.slice(ENC_PREFIX.length);
|
|
21
|
+
const colons = inner.split(":").length - 1;
|
|
22
|
+
const dots = inner.split(".").length - 1;
|
|
23
|
+
if (colons === 2 && dots === 0)
|
|
24
|
+
return "pii";
|
|
25
|
+
if (dots === 2 && colons === 0)
|
|
26
|
+
return "settings";
|
|
27
|
+
return null; // carries the prefix but neither shape — malformed
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Does this value carry the `enc:v1:` prefix at all, whichever system wrote it
|
|
31
|
+
* and even if malformed?
|
|
32
|
+
*
|
|
33
|
+
* This is the check the DISPLAY masks want. `maskName`/`maskEmail` exist so
|
|
34
|
+
* ciphertext never reaches the UI, and for that purpose "settings-encrypted"
|
|
35
|
+
* and "malformed" are just as unshowable as "PII-encrypted". Narrowing them to
|
|
36
|
+
* `kind === "pii"` would let the other two render verbatim.
|
|
37
|
+
*/
|
|
38
|
+
export function hasEncPrefix(value) {
|
|
19
39
|
return typeof value === "string" && value.startsWith(ENC_PREFIX);
|
|
20
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Check if a value is encrypted by our PII system SPECIFICALLY.
|
|
43
|
+
*
|
|
44
|
+
* Use for crypto routing — "may I hand this to decryptValue". For "is this
|
|
45
|
+
* unshowable", use `hasEncPrefix`.
|
|
46
|
+
*/
|
|
47
|
+
export function isPiiEncrypted(value) {
|
|
48
|
+
return encEnvelopeKind(value) === "pii";
|
|
49
|
+
}
|
|
21
50
|
/**
|
|
22
51
|
* Mask a person's name for public display.
|
|
23
52
|
* Each word is reduced to its first letter followed by "***".
|
|
@@ -31,7 +60,7 @@ export function isPiiEncrypted(value) {
|
|
|
31
60
|
export function maskName(name) {
|
|
32
61
|
if (!name || typeof name !== "string")
|
|
33
62
|
return "Anonymous";
|
|
34
|
-
if (
|
|
63
|
+
if (hasEncPrefix(name))
|
|
35
64
|
return "Anonymous";
|
|
36
65
|
return name
|
|
37
66
|
.trim()
|
|
@@ -47,7 +76,7 @@ export function maskName(name) {
|
|
|
47
76
|
export function maskEmail(email) {
|
|
48
77
|
if (!email || typeof email !== "string")
|
|
49
78
|
return "***@***.***";
|
|
50
|
-
if (
|
|
79
|
+
if (hasEncPrefix(email))
|
|
51
80
|
return "***@***.***";
|
|
52
81
|
const atIdx = email.indexOf("@");
|
|
53
82
|
if (atIdx < 0)
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PII Redaction Utility
|
|
3
|
-
*
|
|
4
|
-
* Recursively sanitizes objects before logging by masking or redacting
|
|
5
|
-
* known PII (Personally Identifiable Information) field names.
|
|
6
|
-
*
|
|
7
|
-
* - Secrets (password, token, apiKey, etc.) → "[REDACTED]"
|
|
8
|
-
* - Emails → partial mask: "mo***@gmail.com"
|
|
9
|
-
* - Phones → last 4 digits: "***9876"
|
|
10
|
-
* - Names → first char + asterisks: "J********"
|
|
11
|
-
* - Addresses, financial IDs → "[REDACTED]"
|
|
12
|
-
* - IPs → first octet only: "192.***.***.***"
|
|
13
|
-
*/
|
|
14
1
|
/**
|
|
15
2
|
* Mask an IPv4 address by zeroing the last three octets. Returns
|
|
16
3
|
* `[REDACTED]` for IPv6 / malformed input.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ENC_PREFIX } from "./pii-mask";
|
|
1
2
|
/**
|
|
2
3
|
* PII Redaction Utility
|
|
3
4
|
*
|
|
@@ -138,7 +139,8 @@ export function redactPii(data, depth = 0) {
|
|
|
138
139
|
// ---------------------------------------------------------------------------
|
|
139
140
|
// UI display helpers
|
|
140
141
|
// ---------------------------------------------------------------------------
|
|
141
|
-
|
|
142
|
+
// One declaration of the prefix, shared with the crypto that produces it.
|
|
143
|
+
const ENC_TOKEN_PREFIX = ENC_PREFIX;
|
|
142
144
|
/**
|
|
143
145
|
* Safe display name for untrusted / possibly-encrypted PII fields.
|
|
144
146
|
*
|
|
@@ -66,7 +66,5 @@ export declare const CHAT_PII_FIELDS: readonly ["buyerName", "sellerName"];
|
|
|
66
66
|
export declare const EVENT_ENTRY_PII_FIELDS: readonly ["userDisplayName", "userEmail", "ipAddress"];
|
|
67
67
|
/** PII fields in lottery entries */
|
|
68
68
|
export declare const LOTTERY_ENTRY_PII_FIELDS: readonly ["userPhone", "userEmail", "userDisplayName"];
|
|
69
|
-
/** Store OAuth bearer tokens — encrypted via SETTINGS_ENCRYPTION_KEY */
|
|
70
|
-
export declare const STORE_SECRET_FIELDS: readonly ["whatsappConfig.accessToken"];
|
|
71
69
|
/** PII fields in savedPaymentMethods — full identifier (UPI VPA, card PAN, etc.) */
|
|
72
70
|
export declare const PAYMENT_METHOD_PII_FIELDS: readonly ["identifier"];
|
|
@@ -99,7 +99,21 @@ export const LOTTERY_ENTRY_PII_FIELDS = [
|
|
|
99
99
|
"userEmail",
|
|
100
100
|
"userDisplayName",
|
|
101
101
|
];
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
// `STORE_SECRET_FIELDS = ["whatsappConfig.accessToken"]` was deleted here.
|
|
103
|
+
//
|
|
104
|
+
// It had ZERO consumers, and its presence in this file was the whole hazard:
|
|
105
|
+
// it sat in the PII-field registry, formatted identically to the entries above
|
|
106
|
+
// that ARE assigned to a repository's `piiFields` — while its own doc-comment
|
|
107
|
+
// said it was encrypted with SETTINGS_ENCRYPTION_KEY, the other key entirely.
|
|
108
|
+
//
|
|
109
|
+
// Wiring it into a `piiFields` override would have encrypted every store's Meta
|
|
110
|
+
// bearer token with the PII key while `StoreRepository.decryptSecrets` kept
|
|
111
|
+
// decrypting with the settings key: a hard throw on every store read, and
|
|
112
|
+
// live seller tokens unrecoverable without a bespoke migration. Dotted-path
|
|
113
|
+
// support was added to `encryptPiiFields` in 2026-08, which is exactly what
|
|
114
|
+
// made that one-line mistake possible.
|
|
115
|
+
//
|
|
116
|
+
// The token is handled where it belongs — `encryptSecret`/`decryptSecret` in
|
|
117
|
+
// `StoreRepository`. A constant does not need to exist for that.
|
|
104
118
|
/** PII fields in savedPaymentMethods — full identifier (UPI VPA, card PAN, etc.) */
|
|
105
119
|
export const PAYMENT_METHOD_PII_FIELDS = ["identifier"];
|
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
// crypto is a Node.js built-in. Use require() to keep it out of the static
|
|
2
2
|
// import graph so Next.js Edge bundler does not warn about this file.
|
|
3
3
|
function nodeCrypto() { return require("crypto"); }
|
|
4
|
+
import { ENC_PREFIX, encEnvelopeKind } from "./pii-mask";
|
|
4
5
|
const ALGORITHM = "aes-256-gcm";
|
|
5
6
|
const IV_BYTES = 12;
|
|
6
|
-
|
|
7
|
+
// ONE declaration of the prefix, shared with the PII system that also uses it.
|
|
8
|
+
// Two independent `"enc:v1:"` literals is what let the two formats drift into
|
|
9
|
+
// looking identical to every "is this encrypted" check in the codebase.
|
|
10
|
+
const PREFIX = ENC_PREFIX;
|
|
11
|
+
/**
|
|
12
|
+
* Strip a trailing CR/LF (literal or escaped) and surrounding whitespace —
|
|
13
|
+
* including a leading BOM, which `String.prototype.trim` removes.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors `normalizePiiSecretValue` in pii-encrypt.ts rather than importing
|
|
16
|
+
* it: that module is the Node-crypto half of the PII system and this file must
|
|
17
|
+
* not depend on it. The two are four lines and must stay identical; the
|
|
18
|
+
* UNNORMALISED_HEX_KEY rule in audit-pii-crypto is what keeps them honest.
|
|
19
|
+
*/
|
|
20
|
+
function normalizeSecretEnvValue(raw) {
|
|
21
|
+
return (raw ?? "").replace(/(?:\\r|\\n|\r|\n)+$/g, "").trim();
|
|
22
|
+
}
|
|
7
23
|
function getMasterKey() {
|
|
8
|
-
|
|
24
|
+
// Normalise before measuring AND before Buffer.from. A BOM or trailing CR
|
|
25
|
+
// makes `length !== 64` true, so this threw a confusing "must be 64
|
|
26
|
+
// characters" on a key that looks correct in the dashboard — and had the
|
|
27
|
+
// length check not been there, Buffer.from would have truncated at the first
|
|
28
|
+
// non-hex byte and produced a silently WRONG key instead.
|
|
29
|
+
const hex = normalizeSecretEnvValue(process.env.SETTINGS_ENCRYPTION_KEY);
|
|
9
30
|
if (!hex || hex.length !== 64) {
|
|
10
31
|
throw new Error("SETTINGS_ENCRYPTION_KEY must be a 64-character hex string (32 bytes).");
|
|
11
32
|
}
|
|
@@ -27,6 +48,13 @@ export function decryptSecret(encrypted) {
|
|
|
27
48
|
if (!encrypted.startsWith(PREFIX)) {
|
|
28
49
|
return encrypted;
|
|
29
50
|
}
|
|
51
|
+
// Name the actual problem. A PII-encrypted value reaching here used to throw
|
|
52
|
+
// "expected 3 parts", which describes the symptom and not the cause — it is
|
|
53
|
+
// the wrong KEY and the wrong FORMAT, not a corrupt value.
|
|
54
|
+
const kind = encEnvelopeKind(encrypted);
|
|
55
|
+
if (kind === "pii") {
|
|
56
|
+
throw new Error("Value is PII-encrypted (PII_ENCRYPTION_KEY, colon-separated), not settings-encrypted — decryptSecret cannot read it");
|
|
57
|
+
}
|
|
30
58
|
const parts = encrypted.slice(PREFIX.length).split(".");
|
|
31
59
|
if (parts.length !== 3) {
|
|
32
60
|
throw new Error("Invalid encrypted value - expected 3 parts");
|
|
@@ -41,7 +69,7 @@ export function decryptSecret(encrypted) {
|
|
|
41
69
|
]).toString("utf8");
|
|
42
70
|
}
|
|
43
71
|
export function isSecretEncrypted(value) {
|
|
44
|
-
return value
|
|
72
|
+
return encEnvelopeKind(value) === "settings";
|
|
45
73
|
}
|
|
46
74
|
export function maskSecret(value) {
|
|
47
75
|
if (!value || value.length < 12) {
|
|
@@ -178,6 +178,24 @@ const _rawstoresSeedData = [
|
|
|
178
178
|
},
|
|
179
179
|
isPublic: true,
|
|
180
180
|
isVacationMode: false,
|
|
181
|
+
/*
|
|
182
|
+
* Reachable, but never advertised to search engines.
|
|
183
|
+
*
|
|
184
|
+
* This is a QA store — real enough for a tester to exercise seller flows
|
|
185
|
+
* against, and not something that belongs in Google's index. `isTestData`
|
|
186
|
+
* is what the codebase already uses to express that: the sitemap's store
|
|
187
|
+
* fetcher filters it out (it has no viewer), public store listings hide
|
|
188
|
+
* it, and `canViewTestData()` keeps it visible to testers and admins — so
|
|
189
|
+
* the checklist case linking to `/stores/store-tester-qa-seller` still
|
|
190
|
+
* resolves.
|
|
191
|
+
*
|
|
192
|
+
* 🛑 NOTE the deliberate absence of `testDataExpiresAt`. Every sandbox
|
|
193
|
+
* FIXTURE carries one; this store is permanent. `getTestDataRefs` skips
|
|
194
|
+
* docs without an expiry on the force-purge path precisely so
|
|
195
|
+
* `tester:purge-sandbox --force` cannot delete it. Do not add an expiry
|
|
196
|
+
* here — that would put a permanent seed store in range of the sweep.
|
|
197
|
+
*/
|
|
198
|
+
isTestData: true,
|
|
181
199
|
stats: { totalProducts: 0, itemsSold: 0, totalReviews: 0, averageRating: 0 },
|
|
182
200
|
capabilities: ["host_preorders", "verified_seller", "create_coupons"],
|
|
183
201
|
createdAt: daysAgo(1),
|
package/dist/seed/test-utils.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runSeed } from "./runner";
|
|
2
2
|
import { encryptPiiFields, decryptPiiFields, ENC_PREFIX } from "../security/pii-encrypt";
|
|
3
|
+
import { hasEncPrefix } from "../security/pii-mask";
|
|
3
4
|
import { DEFAULT_CATEGORIES } from "./defaults/categories";
|
|
4
5
|
import { DEFAULT_FAQS } from "./defaults/faqs";
|
|
5
6
|
import { makeProduct } from "./factories/product.factory";
|
|
@@ -24,8 +25,11 @@ export async function assertPiiRoundTrip(doc, piiFields) {
|
|
|
24
25
|
const encrypted = encryptPiiFields(doc, piiFields);
|
|
25
26
|
for (const field of piiFields) {
|
|
26
27
|
const val = encrypted[field];
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
// `hasEncPrefix`, not a bare startsWith: the assertion is "this is not
|
|
29
|
+
// cleartext", which both crypto formats satisfy. And the value NEVER goes
|
|
30
|
+
// in the message — this helper runs over real fixture PII.
|
|
31
|
+
if (!hasEncPrefix(val)) {
|
|
32
|
+
throw new Error(`assertPiiRoundTrip: field "${String(field)}" was NOT encrypted (got ${val.length} chars, no ${ENC_PREFIX} prefix).`);
|
|
29
33
|
}
|
|
30
34
|
// Verify blind index was written
|
|
31
35
|
const indexKey = `${String(field)}Index`;
|
package/dist/ui/DataTable.js
CHANGED
|
@@ -108,7 +108,7 @@ pageSize: pageSizeProp, stickyHeader: stickyHeaderProp, striped: stripedProp, sh
|
|
|
108
108
|
const renderViewToggle = () => {
|
|
109
109
|
if (!showViewToggle)
|
|
110
110
|
return null;
|
|
111
|
-
return (_jsxs(Div, { className: "appkit-data-table__view-toggle", role: "toolbar", "aria-label": "View mode", children: [showTableView && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("table"), "aria-label": tableView, "aria-pressed": activeViewMode === "table", className: `appkit-data-table__view-btn ${activeViewMode === "table" ? "appkit-data-table__view-btn--active" : ""} hidden sm:flex`, children: _jsx("svg", { className: "w-4 h-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 10h18M3 6h18M3 14h18M3 18h18" }) }) })), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("grid"), "aria-label": gridView, "aria-pressed": activeViewMode === "grid", className: `appkit-data-table__view-btn ${activeViewMode === "grid" ? "appkit-data-table__view-btn--active" : ""}`, children: _jsx("svg", { className: "w-4 h-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" }) }) }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("list"), "aria-label": listView, "aria-pressed": activeViewMode === "list", className: `appkit-data-table__view-btn ${activeViewMode === "list" ? "appkit-data-table__view-btn--active" : ""}`, children: _jsx("svg", { className: "w-4 h-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" }) }) })] }));
|
|
111
|
+
return (_jsxs(Div, { className: "appkit-data-table__view-toggle", role: "toolbar", "aria-label": "View mode", children: [showTableView && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("table"), "aria-label": tableView, "aria-pressed": activeViewMode === "table", className: `appkit-data-table__view-btn ${activeViewMode === "table" ? "appkit-data-table__view-btn--active" : ""} hidden sm:flex`, children: _jsx("svg", { className: "w-4 h-4 shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 10h18M3 6h18M3 14h18M3 18h18" }) }) })), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("grid"), "aria-label": gridView, "aria-pressed": activeViewMode === "grid", className: `appkit-data-table__view-btn ${activeViewMode === "grid" ? "appkit-data-table__view-btn--active" : ""}`, children: _jsx("svg", { className: "w-4 h-4 shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" }) }) }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleViewModeChange("list"), "aria-label": listView, "aria-pressed": activeViewMode === "list", className: `appkit-data-table__view-btn ${activeViewMode === "list" ? "appkit-data-table__view-btn--active" : ""}`, children: _jsx("svg", { className: "w-4 h-4 shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" }) }) })] }));
|
|
112
112
|
};
|
|
113
113
|
// --- Card views (grid / list) ------------------------------------------------
|
|
114
114
|
const renderCardGrid = (mode) => {
|
|
@@ -158,5 +158,5 @@ function SelectableCard({ id, selectable, selected, onToggle, children, listMode
|
|
|
158
158
|
selected
|
|
159
159
|
? "border-2 border-primary bg-primary"
|
|
160
160
|
: "border-2 border-zinc-500 dark:border-slate-400 bg-transparent group-hover:border-primary",
|
|
161
|
-
].join(" "), checked: selected, onChange: (e) => onToggle(id, e.target.checked), "aria-label": "Select item" }), selected && (_jsx("svg", { className: "absolute inset-0 m-auto h-3 w-3 text-white pointer-events-none", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 3, d: "M5 13l4 4L19 7" }) }))] }) }) }), selected && (_jsx(Div, { className: "absolute inset-0 z-[var(--appkit-z-raised)] ring-2 ring-primary ring-offset-0 pointer-events-none", rounded: "xl", "aria-hidden": "true" })), children] }));
|
|
161
|
+
].join(" "), checked: selected, onChange: (e) => onToggle(id, e.target.checked), "aria-label": "Select item" }), selected && (_jsx("svg", { className: "absolute inset-0 m-auto h-3 w-3 text-white pointer-events-none shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 3, d: "M5 13l4 4L19 7" }) }))] }) }) }), selected && (_jsx(Div, { className: "absolute inset-0 z-[var(--appkit-z-raised)] ring-2 ring-primary ring-offset-0 pointer-events-none", rounded: "xl", "aria-hidden": "true" })), children] }));
|
|
162
162
|
}
|
|
@@ -9,7 +9,7 @@ import { Collapse } from "./Motion";
|
|
|
9
9
|
* server-side merge/default-collapsed semantics). Purely presentational
|
|
10
10
|
* here: isCollapsed/onToggle are always controlled by the caller. */
|
|
11
11
|
export function CollapsibleSection({ title, isCollapsed, onToggle, renderHeaderExtra, keepMounted = false, id, className = "", children, }) {
|
|
12
|
-
return (_jsxs(Div, { id: id, className: className, rounded: "xl", border: "default", surface: "default", children: [_jsxs(Row, { align: "center", justify: "between", paddingX: "x-md", paddingY: "y-sm", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: onToggle, paddingX: "none", paddingY: "none", rounded: "none", className: "min-w-0 flex-1", "aria-expanded": !isCollapsed, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { weight: "semibold", size: "sm", children: title }), _jsx("svg", { className: `w-4 h-4 transition-transform duration-150 ${isCollapsed ? "" : "rotate-180"}`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M19 9l-7 7-7-7" }) })] }) }), renderHeaderExtra && _jsx(Div, { className: "ml-3 shrink-0", children: renderHeaderExtra() })] }), keepMounted ? (
|
|
12
|
+
return (_jsxs(Div, { id: id, className: className, rounded: "xl", border: "default", surface: "default", children: [_jsxs(Row, { align: "center", justify: "between", paddingX: "x-md", paddingY: "y-sm", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: onToggle, paddingX: "none", paddingY: "none", rounded: "none", className: "min-w-0 flex-1", "aria-expanded": !isCollapsed, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { weight: "semibold", size: "sm", children: title }), _jsx("svg", { className: `w-4 h-4 transition-transform duration-150 ${isCollapsed ? "" : "rotate-180"} shrink-0`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M19 9l-7 7-7-7" }) })] }) }), renderHeaderExtra && _jsx(Div, { className: "ml-3 shrink-0", children: renderHeaderExtra() })] }), keepMounted ? (
|
|
13
13
|
// No <Collapse>: it unmounts on close, which would discard uncommitted
|
|
14
14
|
// child state (see `keepMounted` docs). `hidden` keeps the subtree
|
|
15
15
|
// mounted and removes it from the a11y tree at the same time.
|
|
@@ -107,7 +107,7 @@ export default function ImageGallery({ images, initialIndex = 0, showThumbnails
|
|
|
107
107
|
? "zoom-out"
|
|
108
108
|
: "zoom-in"
|
|
109
109
|
: "default",
|
|
110
|
-
} }), images.length > 1 && !isZoomed && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", onClick: goToPrevious, disabled: currentIndex === 0, className: `appkit-image-gallery__nav-btn appkit-image-gallery__nav-btn--prev ${themed.bgSecondary} ${themed.textPrimary}`, "aria-label": "Previous image", children: _jsx("svg", { className: "w-5 h-5 md:w-6 md:h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }) }), _jsx(Button, { variant: "ghost", onClick: goToNext, disabled: currentIndex === images.length - 1, className: `appkit-image-gallery__nav-btn appkit-image-gallery__nav-btn--next ${themed.bgSecondary} ${themed.textPrimary}`, "aria-label": "Next image", children: _jsx("svg", { className: "w-5 h-5 md:w-6 md:h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 5l7 7-7 7" }) }) })] })), images.length > 1 && (_jsxs("div", { className: `appkit-image-gallery__counter ${themed.bgSecondary} ${themed.textPrimary}`, "data-section": "imagegallery-div-519", children: [currentIndex + 1, " / ", images.length] })), allowZoom && isZoomed && (_jsxs("div", { className: `appkit-image-gallery__zoom-indicator ${themed.bgSecondary} ${themed.textPrimary}`, "data-section": "imagegallery-div-520", children: [Math.round(scale * 100), "%"] }))] }), showCaptions && currentImage.caption && (_jsx("div", { className: `appkit-image-gallery__caption ${themed.border}`, "data-section": "imagegallery-div-521", children: _jsx(Text, { className: themed.textSecondary, children: currentImage.caption }) }))] }), showThumbnails && images.length > 1 && (_jsx(HorizontalScroller, { snapToItems: true, className: "mt-4 pb-2", children: images.map((image, index) => (_jsx(Button, { variant: "ghost", onClick: () => goToIndex(index), className: `appkit-image-gallery__thumbnail ${index === currentIndex
|
|
110
|
+
} }), images.length > 1 && !isZoomed && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", onClick: goToPrevious, disabled: currentIndex === 0, className: `appkit-image-gallery__nav-btn appkit-image-gallery__nav-btn--prev ${themed.bgSecondary} ${themed.textPrimary}`, "aria-label": "Previous image", children: _jsx("svg", { className: "w-5 h-5 md:w-6 md:h-6 shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }) }), _jsx(Button, { variant: "ghost", onClick: goToNext, disabled: currentIndex === images.length - 1, className: `appkit-image-gallery__nav-btn appkit-image-gallery__nav-btn--next ${themed.bgSecondary} ${themed.textPrimary}`, "aria-label": "Next image", children: _jsx("svg", { className: "w-5 h-5 md:w-6 md:h-6 shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 5l7 7-7 7" }) }) })] })), images.length > 1 && (_jsxs("div", { className: `appkit-image-gallery__counter ${themed.bgSecondary} ${themed.textPrimary}`, "data-section": "imagegallery-div-519", children: [currentIndex + 1, " / ", images.length] })), allowZoom && isZoomed && (_jsxs("div", { className: `appkit-image-gallery__zoom-indicator ${themed.bgSecondary} ${themed.textPrimary}`, "data-section": "imagegallery-div-520", children: [Math.round(scale * 100), "%"] }))] }), showCaptions && currentImage.caption && (_jsx("div", { className: `appkit-image-gallery__caption ${themed.border}`, "data-section": "imagegallery-div-521", children: _jsx(Text, { className: themed.textSecondary, children: currentImage.caption }) }))] }), showThumbnails && images.length > 1 && (_jsx(HorizontalScroller, { snapToItems: true, className: "mt-4 pb-2", children: images.map((image, index) => (_jsx(Button, { variant: "ghost", onClick: () => goToIndex(index), className: `appkit-image-gallery__thumbnail ${index === currentIndex
|
|
111
111
|
? "appkit-image-gallery__thumbnail--active"
|
|
112
112
|
: themed.border}`, "aria-label": `View image ${index + 1}`, children: _jsx(MediaImage, { src: image.thumbnail || image.src, alt: image.alt, size: "thumbnail", className: "w-full h-full object-cover" }) }, index))) })), _jsx("div", { className: `appkit-image-gallery__help ${themed.textMuted}`, "data-section": "imagegallery-div-522", children: allowZoom
|
|
113
113
|
? "Swipe to navigate • Double tap to zoom"
|
|
@@ -106,5 +106,5 @@ export function ListingLayout({ headerSlot, statusTabsSlot, filterContent, filte
|
|
|
106
106
|
: l.applyFilters })] }), children: filterContent })), overlays] }));
|
|
107
107
|
}
|
|
108
108
|
function FilterIcon() {
|
|
109
|
-
return (_jsx("svg", { className: "w-4 h-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 2, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" }) }));
|
|
109
|
+
return (_jsx("svg", { className: "w-4 h-4 shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 2, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" }) }));
|
|
110
110
|
}
|
|
@@ -94,25 +94,6 @@ export interface UseFormShellStateResult {
|
|
|
94
94
|
*/
|
|
95
95
|
markSubmitAttempted: () => void;
|
|
96
96
|
}
|
|
97
|
-
/**
|
|
98
|
-
* `applyZodIssues` — pipe every issue from a Zod safeParse failure into the
|
|
99
|
-
* FormShellContext error map. Use after `schema.safeParse(values)` returns
|
|
100
|
-
* `success: false` to surface inline errors on `<FieldInput>` etc.
|
|
101
|
-
*
|
|
102
|
-
* Keys are the full dotted/indexed path (`"video.duration"`, `"images.2"`),
|
|
103
|
-
* not just the top-level segment — a plain `issue.path[0]` key would collapse
|
|
104
|
-
* every nested issue under one object onto the same map entry, silently
|
|
105
|
-
* dropping all but the last. Top-level-only fields (the common case) are
|
|
106
|
-
* unaffected: a single-segment path joins to the same string as before.
|
|
107
|
-
*
|
|
108
|
-
* Root-level `.refine()` issues (empty `path: []`, no owning field) are still
|
|
109
|
-
* skipped — there is no field to attach them to, and no schema in this
|
|
110
|
-
* codebase currently relies on one being surfaced this way.
|
|
111
|
-
*/
|
|
112
|
-
export declare function applyZodIssues(issues: ReadonlyArray<{
|
|
113
|
-
path: ReadonlyArray<PropertyKey>;
|
|
114
|
-
message: string;
|
|
115
|
-
}>, setFieldError: (name: string, error: string | null) => void): void;
|
|
116
97
|
/**
|
|
117
98
|
* `useFormShellState(schema?, nav?)` — caller-owned form state with optional
|
|
118
99
|
* Zod validation. When a schema is supplied, `validate(values)` runs it and
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
4
|
+
import { applyZodIssues } from "./apply-zod-issues";
|
|
4
5
|
// ─── Context ──────────────────────────────────────────────────────────────────
|
|
5
6
|
export const FormShellContext = createContext(null);
|
|
6
7
|
export function useFormShell() {
|
|
@@ -9,29 +10,9 @@ export function useFormShell() {
|
|
|
9
10
|
throw new Error("useFormShell must be used inside <FormShell>");
|
|
10
11
|
return ctx;
|
|
11
12
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* `success: false` to surface inline errors on `<FieldInput>` etc.
|
|
16
|
-
*
|
|
17
|
-
* Keys are the full dotted/indexed path (`"video.duration"`, `"images.2"`),
|
|
18
|
-
* not just the top-level segment — a plain `issue.path[0]` key would collapse
|
|
19
|
-
* every nested issue under one object onto the same map entry, silently
|
|
20
|
-
* dropping all but the last. Top-level-only fields (the common case) are
|
|
21
|
-
* unaffected: a single-segment path joins to the same string as before.
|
|
22
|
-
*
|
|
23
|
-
* Root-level `.refine()` issues (empty `path: []`, no owning field) are still
|
|
24
|
-
* skipped — there is no field to attach them to, and no schema in this
|
|
25
|
-
* codebase currently relies on one being surfaced this way.
|
|
26
|
-
*/
|
|
27
|
-
export function applyZodIssues(issues, setFieldError) {
|
|
28
|
-
for (const issue of issues) {
|
|
29
|
-
if (!issue.path || issue.path.length === 0)
|
|
30
|
-
continue;
|
|
31
|
-
const key = issue.path.map(String).join(".");
|
|
32
|
-
setFieldError(key, issue.message);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
13
|
+
// `applyZodIssues` lives in ./apply-zod-issues — `surface-error.ts`, which is
|
|
14
|
+
// deliberately React-free, needs it to route server-side validation failures
|
|
15
|
+
// to inline field errors and cannot import this "use client" module.
|
|
35
16
|
/**
|
|
36
17
|
* `useFormShellState(schema?, nav?)` — caller-owned form state with optional
|
|
37
18
|
* Zod validation. When a schema is supplied, `validate(values)` runs it and
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyZodIssues` — pipe every issue from a validation failure into a form's
|
|
3
|
+
* error map, so they render inline on `<FieldInput>` etc. rather than as a
|
|
4
|
+
* toast.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS IS ITS OWN FILE
|
|
7
|
+
* ------------------------
|
|
8
|
+
* It used to live in `FormShell.tsx`, which is a `"use client"` React module.
|
|
9
|
+
* `client/api/surface-error.ts` needs this function to route server-side
|
|
10
|
+
* validation failures to inline field errors, and that file is deliberately
|
|
11
|
+
* hook-free and React-free (see its header) so it can be called from outside a
|
|
12
|
+
* hook boundary — e.g. inside Button's non-hook click wrapper. Importing
|
|
13
|
+
* FormShell there would have dragged React and the whole form runtime into it.
|
|
14
|
+
*
|
|
15
|
+
* This is a MOVE, not a barrel re-export: `FormShell.tsx` imports it from here.
|
|
16
|
+
*
|
|
17
|
+
* Keys are the full dotted/indexed path (`"video.duration"`, `"images.2"`),
|
|
18
|
+
* not just the top-level segment — a plain `issue.path[0]` key would collapse
|
|
19
|
+
* every nested issue under one object onto the same map entry, silently
|
|
20
|
+
* dropping all but the last. Top-level-only fields (the common case) are
|
|
21
|
+
* unaffected: a single-segment path joins to the same string as before.
|
|
22
|
+
*
|
|
23
|
+
* Root-level `.refine()` issues (empty `path: []`, no owning field) are still
|
|
24
|
+
* skipped — there is no field to attach them to, so the caller must surface
|
|
25
|
+
* those some other way (they fall through to a toast in `surfaceError`).
|
|
26
|
+
*/
|
|
27
|
+
export declare function applyZodIssues(issues: ReadonlyArray<{
|
|
28
|
+
path?: ReadonlyArray<PropertyKey>;
|
|
29
|
+
message: string;
|
|
30
|
+
}>, setFieldError: (name: string, error: string | null) => void): void;
|
|
31
|
+
/** True when at least one issue carries a field path we can attach it to. */
|
|
32
|
+
export declare function hasAttachableIssue(issues: ReadonlyArray<{
|
|
33
|
+
path?: ReadonlyArray<PropertyKey>;
|
|
34
|
+
}> | undefined): boolean;
|