@mohasinac/appkit 4.5.2 → 4.6.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/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +6 -0
- package/dist/constants/api-endpoints.js +2 -0
- package/dist/contracts/client-auth.d.ts +8 -0
- package/dist/features/auth/actions/index.d.ts +1 -0
- package/dist/features/auth/actions/index.js +1 -0
- package/dist/features/auth/actions/password-change-otp-actions.d.ts +18 -0
- package/dist/features/auth/actions/password-change-otp-actions.js +88 -0
- package/dist/features/auth/hooks/useAuth.d.ts +29 -0
- package/dist/features/auth/hooks/useAuth.js +31 -4
- package/dist/features/auth/password-change-otp.d.ts +34 -0
- package/dist/features/auth/password-change-otp.js +56 -0
- package/dist/features/categories/components/BrandDetailTabs.js +23 -14
- package/dist/features/categories/components/CategoryDetailTabs.js +21 -16
- package/dist/features/stores/components/StoreDetailLayoutView.js +20 -6
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +38 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/providers/firebase-client/auth.d.ts +1 -0
- package/dist/providers/firebase-client/auth.js +7 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +2 -0
- package/dist/validation/schemas.d.ts +4 -4
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -191,7 +191,7 @@ export { AuctionBidsTable } from "./features/auctions/components/AuctionBidsTabl
|
|
|
191
191
|
export type { AuctionBidsTableProps, AuctionWithBids } from "./features/auctions/components/AuctionBidsTable";
|
|
192
192
|
export { ProtectedRoute, AuthStatusPanel, ForgotPasswordView, LoginForm, RegisterForm, ResetPasswordView, VerifyEmailView } from "./features/auth/index";
|
|
193
193
|
export type { AuthGuardUser, ForgotPasswordViewProps, LoginFormProps, LoginFormValues, RegisterFormProps, RegisterFormValues, ResetPasswordViewProps, VerifyEmailViewProps, } from "./features/auth/index";
|
|
194
|
-
export { useLogout, useLogin, useGoogleLogin, useLinkGoogleAccount, useRegister, useForgotPassword, useResetPassword, useVerifyEmail, useChangePassword, useChangeEmail } from "./features/auth/index";
|
|
194
|
+
export { useLogout, useLogin, useGoogleLogin, useLinkGoogleAccount, useRegister, useForgotPassword, useResetPassword, useVerifyEmail, useChangePassword, useRequestPasswordChangeOtp, useVerifyPasswordChangeOtp, useChangeEmail } from "./features/auth/index";
|
|
195
195
|
export type { LoginCredentials, RegisterData, ForgotPasswordData, ResetPasswordData, VerifyEmailData, ChangePasswordData, ChangeEmailData } from "./features/auth/index";
|
|
196
196
|
export { CartView, CartItemRow, CartSummary, CartDrawer, CheckoutView, CheckoutSuccessView, CheckoutAddressStep, useGuestCart, useCartCount, useAddToCart, useCart, useGuestCartMerge, useCartQuery } from "./features/cart/index";
|
|
197
197
|
export type { CartItem, CartItemMeta, CartData, GuestCartItem } from "./features/cart/index";
|
package/dist/client.js
CHANGED
|
@@ -222,7 +222,7 @@ export { PageViewTracker } from "./features/analytics/components/PageViewTracker
|
|
|
222
222
|
export { PAGE_VIEW_ENTITY_TYPES } from "./features/analytics/types";
|
|
223
223
|
export { AuctionBidsTable } from "./features/auctions/components/AuctionBidsTable";
|
|
224
224
|
export { ProtectedRoute, AuthStatusPanel, ForgotPasswordView, LoginForm, RegisterForm, ResetPasswordView, VerifyEmailView } from "./features/auth/index";
|
|
225
|
-
export { useLogout, useLogin, useGoogleLogin, useLinkGoogleAccount, useRegister, useForgotPassword, useResetPassword, useVerifyEmail, useChangePassword, useChangeEmail } from "./features/auth/index";
|
|
225
|
+
export { useLogout, useLogin, useGoogleLogin, useLinkGoogleAccount, useRegister, useForgotPassword, useResetPassword, useVerifyEmail, useChangePassword, useRequestPasswordChangeOtp, useVerifyPasswordChangeOtp, useChangeEmail } from "./features/auth/index";
|
|
226
226
|
export { CartView, CartItemRow, CartSummary, CartDrawer, CheckoutView, CheckoutSuccessView, CheckoutAddressStep, useGuestCart, useCartCount, useAddToCart, useCart, useGuestCartMerge, useCartQuery } from "./features/cart/index";
|
|
227
227
|
export { useAddresses, useCreateAddress, useUpdateAddress, useDeleteAddress, useSetDefaultAddress, useAddress } from "./features/account/index";
|
|
228
228
|
export { AddressBook, AddressCard, AddressForm } from "./features/account/index";
|
|
@@ -32,6 +32,8 @@ export declare const ACCOUNT_ENDPOINTS: {
|
|
|
32
32
|
readonly BY_ID: (userId: string) => string;
|
|
33
33
|
readonly PROFILE: "/api/user/profile";
|
|
34
34
|
readonly CHANGE_PASSWORD: "/api/user/change-password";
|
|
35
|
+
readonly CHANGE_PASSWORD_OTP_REQUEST: "/api/user/change-password/otp/request";
|
|
36
|
+
readonly CHANGE_PASSWORD_OTP_VERIFY: "/api/user/change-password/otp/verify";
|
|
35
37
|
/** @param userId — for public profile pages */
|
|
36
38
|
readonly PUBLIC_PROFILE: (userId: string) => string;
|
|
37
39
|
/** @param userId — seller profile page */
|
|
@@ -481,6 +483,8 @@ export declare const API_ENDPOINTS: {
|
|
|
481
483
|
readonly BY_ID: (userId: string) => string;
|
|
482
484
|
readonly PROFILE: "/api/user/profile";
|
|
483
485
|
readonly CHANGE_PASSWORD: "/api/user/change-password";
|
|
486
|
+
readonly CHANGE_PASSWORD_OTP_REQUEST: "/api/user/change-password/otp/request";
|
|
487
|
+
readonly CHANGE_PASSWORD_OTP_VERIFY: "/api/user/change-password/otp/verify";
|
|
484
488
|
/** @param userId — for public profile pages */
|
|
485
489
|
readonly PUBLIC_PROFILE: (userId: string) => string;
|
|
486
490
|
/** @param userId — seller profile page */
|
|
@@ -932,6 +936,8 @@ export declare const API_ROUTES: {
|
|
|
932
936
|
readonly BY_ID: (userId: string) => string;
|
|
933
937
|
readonly PROFILE: "/api/user/profile";
|
|
934
938
|
readonly CHANGE_PASSWORD: "/api/user/change-password";
|
|
939
|
+
readonly CHANGE_PASSWORD_OTP_REQUEST: "/api/user/change-password/otp/request";
|
|
940
|
+
readonly CHANGE_PASSWORD_OTP_VERIFY: "/api/user/change-password/otp/verify";
|
|
935
941
|
/** @param userId — for public profile pages */
|
|
936
942
|
readonly PUBLIC_PROFILE: (userId: string) => string;
|
|
937
943
|
/** @param userId — seller profile page */
|
|
@@ -47,6 +47,8 @@ export const ACCOUNT_ENDPOINTS = {
|
|
|
47
47
|
BY_ID: (userId) => `/api/account/${userId}`,
|
|
48
48
|
PROFILE: "/api/user/profile",
|
|
49
49
|
CHANGE_PASSWORD: "/api/user/change-password",
|
|
50
|
+
CHANGE_PASSWORD_OTP_REQUEST: "/api/user/change-password/otp/request",
|
|
51
|
+
CHANGE_PASSWORD_OTP_VERIFY: "/api/user/change-password/otp/verify",
|
|
50
52
|
/** @param userId — for public profile pages */
|
|
51
53
|
PUBLIC_PROFILE: (userId) => `/api/profile/${userId}`,
|
|
52
54
|
/** @param userId — seller profile page */
|
|
@@ -15,6 +15,14 @@ export interface IClientAuthProvider {
|
|
|
15
15
|
confirmPasswordReset(code: string, newPassword: string): Promise<void>;
|
|
16
16
|
/** Re-authenticate with current password and change to new password */
|
|
17
17
|
reauthenticateAndChangePassword(currentPassword: string, newPassword: string): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Verify the current password only — does NOT change anything. Used to
|
|
20
|
+
* prove current-password knowledge before an OTP is sent, without the
|
|
21
|
+
* side effect of immediately applying a new password ahead of that OTP
|
|
22
|
+
* being verified (see password-change-otp.ts for why the two steps must
|
|
23
|
+
* stay separate).
|
|
24
|
+
*/
|
|
25
|
+
reauthenticateOnly(currentPassword: string): Promise<void>;
|
|
18
26
|
/** Re-authenticate and send a verification email to the new address; email updates after user clicks the link */
|
|
19
27
|
reauthenticateAndSendEmailUpdateVerification(currentPassword: string, newEmail: string): Promise<void>;
|
|
20
28
|
/** Reload the current user's profile */
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password Change OTP Actions.
|
|
3
|
+
*
|
|
4
|
+
* Server-side gate for POST /api/user/change-password — see
|
|
5
|
+
* ../password-change-otp.ts for the full incident writeup. Mirrors
|
|
6
|
+
* checkout-value-otp-actions.ts's shape exactly (same crypto primitives,
|
|
7
|
+
* separate Firestore namespace and copy).
|
|
8
|
+
*/
|
|
9
|
+
/** Sends a password-change OTP to the account's registered email. Returns masked email so the UI can confirm which inbox to check. */
|
|
10
|
+
export declare function sendPasswordChangeOtp(userId: string, userEmail: string): Promise<{
|
|
11
|
+
maskedEmail: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** Verifies the 6-digit password-change OTP and marks the Firestore record verified. */
|
|
14
|
+
export declare function verifyPasswordChangeOtp(userId: string, code: string): Promise<void>;
|
|
15
|
+
/** Whether the account has a currently-verified password-change OTP on file. */
|
|
16
|
+
export declare function isPasswordChangeOtpVerified(userId: string): Promise<boolean>;
|
|
17
|
+
/** Consumes the verified OTP so it can't be reused for a future password change. */
|
|
18
|
+
export declare function consumePasswordChangeOtp(userId: string): Promise<void>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password Change OTP Actions.
|
|
3
|
+
*
|
|
4
|
+
* Server-side gate for POST /api/user/change-password — see
|
|
5
|
+
* ../password-change-otp.ts for the full incident writeup. Mirrors
|
|
6
|
+
* checkout-value-otp-actions.ts's shape exactly (same crypto primitives,
|
|
7
|
+
* separate Firestore namespace and copy).
|
|
8
|
+
*/
|
|
9
|
+
import { timingSafeEqual } from "crypto";
|
|
10
|
+
import { ValidationError } from "../../../errors";
|
|
11
|
+
import { serverLogger } from "../../../monitoring";
|
|
12
|
+
import { sendEmail } from "../../contact/email";
|
|
13
|
+
import { getAdminDb } from "../../../providers/db-firebase";
|
|
14
|
+
import { resolveDate } from "../../../utils";
|
|
15
|
+
import { hashOtp, generateOtpCode, PASSWORD_CHANGE_OTP_EXPIRY_MS, PASSWORD_CHANGE_OTP_MAX_ATTEMPTS, passwordChangeOtpRef, enforcePasswordChangeOtpRateLimit, } from "../password-change-otp";
|
|
16
|
+
/** Sends a password-change OTP to the account's registered email. Returns masked email so the UI can confirm which inbox to check. */
|
|
17
|
+
export async function sendPasswordChangeOtp(userId, userEmail) {
|
|
18
|
+
const db = getAdminDb();
|
|
19
|
+
await enforcePasswordChangeOtpRateLimit(db, userId);
|
|
20
|
+
const code = generateOtpCode();
|
|
21
|
+
const codeHash = hashOtp(code);
|
|
22
|
+
const expiresAt = new Date(Date.now() + PASSWORD_CHANGE_OTP_EXPIRY_MS);
|
|
23
|
+
await passwordChangeOtpRef(db, userId).set({
|
|
24
|
+
codeHash,
|
|
25
|
+
expiresAt,
|
|
26
|
+
attempts: 0,
|
|
27
|
+
verified: false,
|
|
28
|
+
createdAt: new Date(),
|
|
29
|
+
});
|
|
30
|
+
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || "LetItRip";
|
|
31
|
+
const [local, domain] = userEmail.split("@");
|
|
32
|
+
const maskedEmail = domain
|
|
33
|
+
? `${local.length <= 2 ? "*".repeat(local.length) : local[0] + "*".repeat(local.length - 2) + local[local.length - 1]}@${domain}`
|
|
34
|
+
: "***";
|
|
35
|
+
await sendEmail({
|
|
36
|
+
to: userEmail,
|
|
37
|
+
subject: `${siteName}: Verify your password change`,
|
|
38
|
+
html: `
|
|
39
|
+
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
|
|
40
|
+
<h2 style="margin-bottom:8px">Confirm Password Change</h2>
|
|
41
|
+
<p style="color:#555">Someone requested to change this account's password. Enter this code to confirm it was you:</p>
|
|
42
|
+
<div style="font-size:36px;font-weight:bold;letter-spacing:8px;text-align:center;margin:24px 0;padding:16px;background:#f3f4f6;border-radius:8px">${code}</div>
|
|
43
|
+
<p style="color:#888;font-size:12px">This code expires in 10 minutes. If you did not request this, your password has NOT been changed — but consider securing your account.</p>
|
|
44
|
+
</div>
|
|
45
|
+
`,
|
|
46
|
+
});
|
|
47
|
+
serverLogger.info(`Password change OTP sent: uid=${userId}`);
|
|
48
|
+
return { maskedEmail };
|
|
49
|
+
}
|
|
50
|
+
/** Verifies the 6-digit password-change OTP and marks the Firestore record verified. */
|
|
51
|
+
export async function verifyPasswordChangeOtp(userId, code) {
|
|
52
|
+
const db = getAdminDb();
|
|
53
|
+
const ref = passwordChangeOtpRef(db, userId);
|
|
54
|
+
const snap = await ref.get();
|
|
55
|
+
if (!snap.exists) {
|
|
56
|
+
throw new ValidationError("No verification code found. Please request a new code.");
|
|
57
|
+
}
|
|
58
|
+
const otpDoc = snap.data();
|
|
59
|
+
if (otpDoc.verified)
|
|
60
|
+
return;
|
|
61
|
+
if (otpDoc.attempts >= PASSWORD_CHANGE_OTP_MAX_ATTEMPTS) {
|
|
62
|
+
throw new ValidationError("Too many failed attempts. Please request a new code.");
|
|
63
|
+
}
|
|
64
|
+
if (Date.now() > (resolveDate(otpDoc.expiresAt)?.getTime() ?? 0)) {
|
|
65
|
+
throw new ValidationError("Code expired. Please request a new one.");
|
|
66
|
+
}
|
|
67
|
+
const inputHash = hashOtp(code);
|
|
68
|
+
if (!timingSafeEqual(Buffer.from(inputHash, "hex"), Buffer.from(otpDoc.codeHash, "hex"))) {
|
|
69
|
+
await ref.update({ attempts: otpDoc.attempts + 1 });
|
|
70
|
+
throw new ValidationError("Invalid code. Please check and try again.");
|
|
71
|
+
}
|
|
72
|
+
await ref.update({ verified: true, verifiedAt: new Date() });
|
|
73
|
+
serverLogger.info(`Password change OTP verified: uid=${userId}`);
|
|
74
|
+
}
|
|
75
|
+
/** Whether the account has a currently-verified password-change OTP on file. */
|
|
76
|
+
export async function isPasswordChangeOtpVerified(userId) {
|
|
77
|
+
const db = getAdminDb();
|
|
78
|
+
const snap = await passwordChangeOtpRef(db, userId).get();
|
|
79
|
+
if (!snap.exists)
|
|
80
|
+
return false;
|
|
81
|
+
const otpDoc = snap.data();
|
|
82
|
+
return otpDoc.verified === true;
|
|
83
|
+
}
|
|
84
|
+
/** Consumes the verified OTP so it can't be reused for a future password change. */
|
|
85
|
+
export async function consumePasswordChangeOtp(userId) {
|
|
86
|
+
const db = getAdminDb();
|
|
87
|
+
await passwordChangeOtpRef(db, userId).delete();
|
|
88
|
+
}
|
|
@@ -90,6 +90,35 @@ export declare function useResetPassword(options?: {
|
|
|
90
90
|
onSuccess?: (data: JsonValue) => void;
|
|
91
91
|
onError?: (error: Error) => void;
|
|
92
92
|
}): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, ResetPasswordData, unknown>;
|
|
93
|
+
/**
|
|
94
|
+
* Password change is now a 3-step flow (root-caused 2026-08-20 — see
|
|
95
|
+
* appkit/src/features/auth/password-change-otp.ts): the old single-step
|
|
96
|
+
* reauthenticateAndChangePassword() applied the new password immediately
|
|
97
|
+
* client-side, before any server-verified identity check beyond the
|
|
98
|
+
* session cookie. Step order:
|
|
99
|
+
* 1. useRequestPasswordChangeOtp — verifies currentPassword via Firebase
|
|
100
|
+
* reauth (proves the caller actually knows it, without changing
|
|
101
|
+
* anything yet), then emails a 6-digit code.
|
|
102
|
+
* 2. useVerifyPasswordChangeOtp — verifies the code server-side.
|
|
103
|
+
* 3. useChangePassword — only now actually applies newPassword; the API
|
|
104
|
+
* route rejects this call unless step 2 already succeeded.
|
|
105
|
+
*/
|
|
106
|
+
export declare function useRequestPasswordChangeOtp(options?: {
|
|
107
|
+
onSuccess?: (data: {
|
|
108
|
+
maskedEmail: string;
|
|
109
|
+
}) => void;
|
|
110
|
+
onError?: (error: Error) => void;
|
|
111
|
+
}): import("@tanstack/react-query").UseMutationResult<{
|
|
112
|
+
maskedEmail: string;
|
|
113
|
+
}, Error, {
|
|
114
|
+
currentPassword: string;
|
|
115
|
+
}, unknown>;
|
|
116
|
+
export declare function useVerifyPasswordChangeOtp(options?: {
|
|
117
|
+
onSuccess?: (data: JsonValue) => void;
|
|
118
|
+
onError?: (error: Error) => void;
|
|
119
|
+
}): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, {
|
|
120
|
+
code: string;
|
|
121
|
+
}, unknown>;
|
|
93
122
|
export declare function useChangePassword(options?: {
|
|
94
123
|
onSuccess?: (data: JsonValue) => void;
|
|
95
124
|
onError?: (error: Error) => void;
|
|
@@ -353,16 +353,43 @@ export function useResetPassword(options) {
|
|
|
353
353
|
onError: options?.onError,
|
|
354
354
|
});
|
|
355
355
|
}
|
|
356
|
-
|
|
356
|
+
/**
|
|
357
|
+
* Password change is now a 3-step flow (root-caused 2026-08-20 — see
|
|
358
|
+
* appkit/src/features/auth/password-change-otp.ts): the old single-step
|
|
359
|
+
* reauthenticateAndChangePassword() applied the new password immediately
|
|
360
|
+
* client-side, before any server-verified identity check beyond the
|
|
361
|
+
* session cookie. Step order:
|
|
362
|
+
* 1. useRequestPasswordChangeOtp — verifies currentPassword via Firebase
|
|
363
|
+
* reauth (proves the caller actually knows it, without changing
|
|
364
|
+
* anything yet), then emails a 6-digit code.
|
|
365
|
+
* 2. useVerifyPasswordChangeOtp — verifies the code server-side.
|
|
366
|
+
* 3. useChangePassword — only now actually applies newPassword; the API
|
|
367
|
+
* route rejects this call unless step 2 already succeeded.
|
|
368
|
+
*/
|
|
369
|
+
export function useRequestPasswordChangeOtp(options) {
|
|
357
370
|
return useMutation({
|
|
358
|
-
mutationFn: async (
|
|
359
|
-
await getClientAuthProvider().
|
|
360
|
-
return apiClient.post(ACCOUNT_ENDPOINTS.
|
|
371
|
+
mutationFn: async ({ currentPassword }) => {
|
|
372
|
+
await getClientAuthProvider().reauthenticateOnly(currentPassword);
|
|
373
|
+
return apiClient.post(ACCOUNT_ENDPOINTS.CHANGE_PASSWORD_OTP_REQUEST, {});
|
|
361
374
|
},
|
|
362
375
|
onSuccess: options?.onSuccess,
|
|
363
376
|
onError: options?.onError,
|
|
364
377
|
});
|
|
365
378
|
}
|
|
379
|
+
export function useVerifyPasswordChangeOtp(options) {
|
|
380
|
+
return useMutation({
|
|
381
|
+
mutationFn: async ({ code }) => apiClient.post(ACCOUNT_ENDPOINTS.CHANGE_PASSWORD_OTP_VERIFY, { code }),
|
|
382
|
+
onSuccess: options?.onSuccess,
|
|
383
|
+
onError: options?.onError,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
export function useChangePassword(options) {
|
|
387
|
+
return useMutation({
|
|
388
|
+
mutationFn: async (data) => apiClient.post(ACCOUNT_ENDPOINTS.CHANGE_PASSWORD, data),
|
|
389
|
+
onSuccess: options?.onSuccess,
|
|
390
|
+
onError: options?.onError,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
366
393
|
export function useChangeEmail(options) {
|
|
367
394
|
return useMutation({
|
|
368
395
|
mutationFn: async (data) => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password Change OTP.
|
|
3
|
+
*
|
|
4
|
+
* Root-caused 2026-08-20: POST /api/user/change-password validated the
|
|
5
|
+
* *shape* of `currentPassword` (Zod: non-empty, differs from newPassword)
|
|
6
|
+
* but the handler never read or verified it — `getAdminAuth().updateUser()`
|
|
7
|
+
* overwrote the password unconditionally for whatever `uid` the session
|
|
8
|
+
* cookie carried. The only real current-password check
|
|
9
|
+
* (`reauthenticateWithCredential` in the Firebase client SDK) happens in
|
|
10
|
+
* the browser and is trivially bypassed by calling the API directly with a
|
|
11
|
+
* stolen session cookie — a session hijack (XSS, log leak, etc.) was
|
|
12
|
+
* enough to take over the account's password with zero server-enforced
|
|
13
|
+
* identity check. This module gates the actual server-side write behind an
|
|
14
|
+
* email OTP, verified server-side, mirroring the Tier PP checkout-value-OTP
|
|
15
|
+
* pattern exactly (same crypto primitives, reused not duplicated) so a
|
|
16
|
+
* session-cookie thief also needs access to the account's inbox.
|
|
17
|
+
*/
|
|
18
|
+
import { hashOtp, generateOtpCode, CHECKOUT_VALUE_OTP_EXPIRY_MS as PASSWORD_CHANGE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_EXPIRY_MINUTES as PASSWORD_CHANGE_OTP_EXPIRY_MINUTES, CHECKOUT_VALUE_OTP_COOLDOWN_MS as PASSWORD_CHANGE_OTP_COOLDOWN_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS as PASSWORD_CHANGE_OTP_MAX_ATTEMPTS, CHECKOUT_VALUE_OTP_VERIFY_RATE_LIMIT as PASSWORD_CHANGE_OTP_VERIFY_RATE_LIMIT } from "./checkout-value-otp";
|
|
19
|
+
export { hashOtp, generateOtpCode, PASSWORD_CHANGE_OTP_EXPIRY_MS, PASSWORD_CHANGE_OTP_EXPIRY_MINUTES, PASSWORD_CHANGE_OTP_COOLDOWN_MS, PASSWORD_CHANGE_OTP_MAX_ATTEMPTS, PASSWORD_CHANGE_OTP_VERIFY_RATE_LIMIT, };
|
|
20
|
+
type Db = FirebaseFirestore.Firestore;
|
|
21
|
+
/** Single-slot doc per user — a user can only be mid-password-change once. */
|
|
22
|
+
export declare function passwordChangeOtpRef(db: Db, uid: string): FirebaseFirestore.DocumentReference<FirebaseFirestore.DocumentData, FirebaseFirestore.DocumentData>;
|
|
23
|
+
export declare function passwordChangeOtpRateLimitRef(db: Db, uid: string): FirebaseFirestore.DocumentReference<FirebaseFirestore.DocumentData, FirebaseFirestore.DocumentData>;
|
|
24
|
+
export interface PasswordChangeOtpDoc {
|
|
25
|
+
codeHash: string;
|
|
26
|
+
expiresAt: FirebaseFirestore.Timestamp;
|
|
27
|
+
attempts: number;
|
|
28
|
+
verified: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Enforce the per-user send rate-limit.
|
|
32
|
+
* Throws AuthorizationError("passwordChangeOtpRateLimit") when throttled.
|
|
33
|
+
*/
|
|
34
|
+
export declare function enforcePasswordChangeOtpRateLimit(db: Db, uid: string): Promise<void>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password Change OTP.
|
|
3
|
+
*
|
|
4
|
+
* Root-caused 2026-08-20: POST /api/user/change-password validated the
|
|
5
|
+
* *shape* of `currentPassword` (Zod: non-empty, differs from newPassword)
|
|
6
|
+
* but the handler never read or verified it — `getAdminAuth().updateUser()`
|
|
7
|
+
* overwrote the password unconditionally for whatever `uid` the session
|
|
8
|
+
* cookie carried. The only real current-password check
|
|
9
|
+
* (`reauthenticateWithCredential` in the Firebase client SDK) happens in
|
|
10
|
+
* the browser and is trivially bypassed by calling the API directly with a
|
|
11
|
+
* stolen session cookie — a session hijack (XSS, log leak, etc.) was
|
|
12
|
+
* enough to take over the account's password with zero server-enforced
|
|
13
|
+
* identity check. This module gates the actual server-side write behind an
|
|
14
|
+
* email OTP, verified server-side, mirroring the Tier PP checkout-value-OTP
|
|
15
|
+
* pattern exactly (same crypto primitives, reused not duplicated) so a
|
|
16
|
+
* session-cookie thief also needs access to the account's inbox.
|
|
17
|
+
*/
|
|
18
|
+
import { AuthorizationError } from "../../errors";
|
|
19
|
+
import { resolveDate } from "../../utils";
|
|
20
|
+
import { USER_COLLECTION } from "./schemas";
|
|
21
|
+
import { hashOtp, generateOtpCode, CHECKOUT_VALUE_OTP_EXPIRY_MS as PASSWORD_CHANGE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_EXPIRY_MINUTES as PASSWORD_CHANGE_OTP_EXPIRY_MINUTES, CHECKOUT_VALUE_OTP_COOLDOWN_MS as PASSWORD_CHANGE_OTP_COOLDOWN_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS as PASSWORD_CHANGE_OTP_MAX_ATTEMPTS, CHECKOUT_VALUE_OTP_VERIFY_RATE_LIMIT as PASSWORD_CHANGE_OTP_VERIFY_RATE_LIMIT, } from "./checkout-value-otp";
|
|
22
|
+
export { hashOtp, generateOtpCode, PASSWORD_CHANGE_OTP_EXPIRY_MS, PASSWORD_CHANGE_OTP_EXPIRY_MINUTES, PASSWORD_CHANGE_OTP_COOLDOWN_MS, PASSWORD_CHANGE_OTP_MAX_ATTEMPTS, PASSWORD_CHANGE_OTP_VERIFY_RATE_LIMIT, };
|
|
23
|
+
/** Single-slot doc per user — a user can only be mid-password-change once. */
|
|
24
|
+
export function passwordChangeOtpRef(db, uid) {
|
|
25
|
+
return db
|
|
26
|
+
.collection(USER_COLLECTION)
|
|
27
|
+
.doc(uid)
|
|
28
|
+
.collection("passwordChangeOtps")
|
|
29
|
+
.doc("current");
|
|
30
|
+
}
|
|
31
|
+
export function passwordChangeOtpRateLimitRef(db, uid) {
|
|
32
|
+
return db
|
|
33
|
+
.collection(USER_COLLECTION)
|
|
34
|
+
.doc(uid)
|
|
35
|
+
.collection("passwordChangeOtpRateLimit")
|
|
36
|
+
.doc("meta");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Enforce the per-user send rate-limit.
|
|
40
|
+
* Throws AuthorizationError("passwordChangeOtpRateLimit") when throttled.
|
|
41
|
+
*/
|
|
42
|
+
export async function enforcePasswordChangeOtpRateLimit(db, uid) {
|
|
43
|
+
const metaRef = passwordChangeOtpRateLimitRef(db, uid);
|
|
44
|
+
await db.runTransaction(async (tx) => {
|
|
45
|
+
const metaSnap = await tx.get(metaRef);
|
|
46
|
+
const meta = metaSnap.exists
|
|
47
|
+
? metaSnap.data()
|
|
48
|
+
: null;
|
|
49
|
+
const lastSentMs = resolveDate(meta?.lastSentAt)?.getTime() ?? 0;
|
|
50
|
+
const elapsed = Date.now() - lastSentMs;
|
|
51
|
+
if (elapsed < PASSWORD_CHANGE_OTP_COOLDOWN_MS) {
|
|
52
|
+
throw new AuthorizationError("passwordChangeOtpRateLimit");
|
|
53
|
+
}
|
|
54
|
+
tx.set(metaRef, { lastSentAt: new Date() }, { merge: true });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -16,20 +16,6 @@ const TAB_TYPE_MAP = {
|
|
|
16
16
|
bundles: { kind: "category", type: "bundle" },
|
|
17
17
|
};
|
|
18
18
|
export function BrandDetailTabs({ brandName, initialProductsData, initialBundles = [], counts, enabledListingTypes, enabledCategoryTypes, }) {
|
|
19
|
-
const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
|
|
20
|
-
const mapping = TAB_TYPE_MAP[t.id];
|
|
21
|
-
if (!mapping)
|
|
22
|
-
return true;
|
|
23
|
-
if (mapping.kind === "listing" && enabledListingTypes) {
|
|
24
|
-
return enabledListingTypes.includes(mapping.type);
|
|
25
|
-
}
|
|
26
|
-
if (mapping.kind === "category" && enabledCategoryTypes) {
|
|
27
|
-
return enabledCategoryTypes.includes(mapping.type);
|
|
28
|
-
}
|
|
29
|
-
return true;
|
|
30
|
-
});
|
|
31
|
-
const firstTabId = (visibleTabs[0]?.id ?? "products");
|
|
32
|
-
const [activeTab, setActiveTab] = useState(firstTabId);
|
|
33
19
|
const countFor = (id) => {
|
|
34
20
|
switch (id) {
|
|
35
21
|
case "products": return counts?.products;
|
|
@@ -40,5 +26,28 @@ export function BrandDetailTabs({ brandName, initialProductsData, initialBundles
|
|
|
40
26
|
default: return undefined;
|
|
41
27
|
}
|
|
42
28
|
};
|
|
29
|
+
const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
|
|
30
|
+
const mapping = TAB_TYPE_MAP[t.id];
|
|
31
|
+
// This view only has content renderers for the 5 tab ids in
|
|
32
|
+
// TAB_TYPE_MAP (products/auctions/pre-orders/prize-draws/bundles) — any
|
|
33
|
+
// other CATEGORY_PAGE_TABS id (stores, classifieds, etc.) has no case in
|
|
34
|
+
// the render switch below and would show a blank tab, so exclude those
|
|
35
|
+
// here rather than rely on downstream JSX to silently render nothing.
|
|
36
|
+
if (!mapping)
|
|
37
|
+
return false;
|
|
38
|
+
if (mapping.kind === "listing" && enabledListingTypes) {
|
|
39
|
+
if (!enabledListingTypes.includes(mapping.type))
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (mapping.kind === "category" && enabledCategoryTypes) {
|
|
43
|
+
if (!enabledCategoryTypes.includes(mapping.type))
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
// Hide a tab only when its count is known and explicitly zero.
|
|
47
|
+
const count = countFor(t.id);
|
|
48
|
+
return count === undefined || count > 0;
|
|
49
|
+
});
|
|
50
|
+
const firstTabId = (visibleTabs[0]?.id ?? "products");
|
|
51
|
+
const [activeTab, setActiveTab] = useState(firstTabId);
|
|
43
52
|
return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), activeTab === "products" && (_jsx(CategoryProductsListing, { categorySlug: "", brandName: brandName, initialData: initialProductsData })), activeTab === "auctions" && (_jsx(AuctionsIndexListing, { brandName: brandName })), activeTab === "pre-orders" && (_jsx(PreOrdersIndexListing, { brandName: brandName })), activeTab === "prize-draws" && (_jsx(PrizeDrawsIndexListing, { brandName: brandName })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles, brandName: brandName }))] }));
|
|
44
53
|
}
|
|
@@ -19,22 +19,6 @@ const TAB_TYPE_MAP = {
|
|
|
19
19
|
stores: { kind: "entity", type: "stores" },
|
|
20
20
|
};
|
|
21
21
|
export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsData, initialBundles = [], initialStores = [], counts, enabledListingTypes, enabledCategoryTypes, }) {
|
|
22
|
-
const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
|
|
23
|
-
const mapping = TAB_TYPE_MAP[t.id];
|
|
24
|
-
if (!mapping)
|
|
25
|
-
return true;
|
|
26
|
-
if (mapping.kind === "listing" && enabledListingTypes) {
|
|
27
|
-
return enabledListingTypes.includes(mapping.type);
|
|
28
|
-
}
|
|
29
|
-
if (mapping.kind === "category" && enabledCategoryTypes) {
|
|
30
|
-
return enabledCategoryTypes.includes(mapping.type);
|
|
31
|
-
}
|
|
32
|
-
if (mapping.kind === "entity")
|
|
33
|
-
return true;
|
|
34
|
-
return true;
|
|
35
|
-
});
|
|
36
|
-
const firstTabId = (visibleTabs[0]?.id ?? "products");
|
|
37
|
-
const [activeTab, setActiveTab] = useState(firstTabId);
|
|
38
22
|
const countFor = (id) => {
|
|
39
23
|
switch (id) {
|
|
40
24
|
case "products": return counts?.products;
|
|
@@ -46,5 +30,26 @@ export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsDa
|
|
|
46
30
|
default: return undefined;
|
|
47
31
|
}
|
|
48
32
|
};
|
|
33
|
+
const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
|
|
34
|
+
const mapping = TAB_TYPE_MAP[t.id];
|
|
35
|
+
if (mapping) {
|
|
36
|
+
if (mapping.kind === "listing" && enabledListingTypes) {
|
|
37
|
+
if (!enabledListingTypes.includes(mapping.type))
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (mapping.kind === "category" && enabledCategoryTypes) {
|
|
41
|
+
if (!enabledCategoryTypes.includes(mapping.type))
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Hide a tab only when its count is known and explicitly zero — a tab
|
|
46
|
+
// whose count was never fetched (undefined) stays visible so we don't
|
|
47
|
+
// silently hide a listing type this page hasn't wired count-tracking
|
|
48
|
+
// for yet.
|
|
49
|
+
const count = countFor(t.id);
|
|
50
|
+
return count === undefined || count > 0;
|
|
51
|
+
});
|
|
52
|
+
const firstTabId = (visibleTabs[0]?.id ?? "products");
|
|
53
|
+
const [activeTab, setActiveTab] = useState(firstTabId);
|
|
49
54
|
return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), activeTab === "products" && (_jsx(CategoryProductsListing, { categorySlug: categorySlug, categoryId: categoryId, initialData: initialProductsData })), activeTab === "auctions" && (_jsx(AuctionsIndexListing, { categorySlug: categorySlug })), activeTab === "pre-orders" && (_jsx(PreOrdersIndexListing, { categorySlug: categorySlug })), activeTab === "prize-draws" && (_jsx(PrizeDrawsIndexListing, { categorySlug: categorySlug })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles })), activeTab === "stores" && (_jsx(CategoryStoresListing, { stores: initialStores }))] }));
|
|
50
55
|
}
|
|
@@ -111,14 +111,24 @@ export async function StoreDetailLayoutView({ storeSlug, activeTab, children, sc
|
|
|
111
111
|
live: "live",
|
|
112
112
|
};
|
|
113
113
|
const visibleStoreTabs = STORE_PAGE_TABS.filter((tab) => {
|
|
114
|
-
if (tab.id === "bundles")
|
|
115
|
-
|
|
114
|
+
if (tab.id === "bundles") {
|
|
115
|
+
if (!isCategoryTypeEnabled("bundle", settings))
|
|
116
|
+
return false;
|
|
117
|
+
return listingCounts[tab.id] > 0;
|
|
118
|
+
}
|
|
116
119
|
// Combined tab — visible if either underlying listing type is enabled.
|
|
117
120
|
if (tab.id === "art") {
|
|
118
|
-
|
|
121
|
+
if (!(isListingTypeEnabled("art", settings) || isListingTypeEnabled("stickers", settings)))
|
|
122
|
+
return false;
|
|
123
|
+
return listingCounts[tab.id] > 0;
|
|
119
124
|
}
|
|
120
125
|
const lt = TAB_LISTING_TYPE[tab.id];
|
|
121
|
-
|
|
126
|
+
if (lt && !isListingTypeEnabled(lt, settings))
|
|
127
|
+
return false;
|
|
128
|
+
// A store with zero items of a given listing type shouldn't offer a tab
|
|
129
|
+
// that leads to an empty page — matches the "hide empty tab" rule below
|
|
130
|
+
// for coupons/reviews.
|
|
131
|
+
return listingCounts[tab.id] > 0;
|
|
122
132
|
});
|
|
123
133
|
const dropdownTabs = visibleStoreTabs.map((tab) => ({
|
|
124
134
|
value: tab.id,
|
|
@@ -126,8 +136,12 @@ export async function StoreDetailLayoutView({ storeSlug, activeTab, children, sc
|
|
|
126
136
|
href: STORE_LISTING_HREF[tab.id](storeSlug),
|
|
127
137
|
}));
|
|
128
138
|
const tabs = [
|
|
129
|
-
|
|
130
|
-
|
|
139
|
+
...(couponsCount > 0
|
|
140
|
+
? [{ value: "coupons", label: tabLabel("Coupons", couponsCount), href: String(ROUTES.PUBLIC.STORE_COUPONS(storeSlug)) }]
|
|
141
|
+
: []),
|
|
142
|
+
...(reviewsCount > 0
|
|
143
|
+
? [{ value: "reviews", label: tabLabel("Reviews", reviewsCount), href: String(ROUTES.PUBLIC.STORE_REVIEWS(storeSlug)) }]
|
|
144
|
+
: []),
|
|
131
145
|
{ value: "about", label: "About", href: String(ROUTES.PUBLIC.STORE_ABOUT(storeSlug)) },
|
|
132
146
|
];
|
|
133
147
|
return (_jsxs(Main, { children: [_jsx(StoreHeader, { store: store, trust: trust }), _jsxs(Container, { size: "xl", className: "mt-6", children: [_jsx(StoreNavTabs, { dropdownTabs: dropdownTabs, dropdownPlaceholder: "Browse listings", tabs: tabs, activeValue: activeTab }), _jsx(Section, { padding: "t-lg", children: children })] })] }));
|
|
@@ -128,6 +128,38 @@ const rawTesterChecklistItems = [
|
|
|
128
128
|
description: "Verify against the seeded fixtures: \"Test Collectible — Sold Out\" (standard, hidden until \"Show sold\" is on), \"Test Auction — Already Won\" (hidden until \"Show ended\" is on), \"Test Prize Draw — Already Closed\" (hidden until \"Show closed\" is on). All three should be genuinely absent by default, not just from an unrelated broken query.",
|
|
129
129
|
href: "/products",
|
|
130
130
|
},
|
|
131
|
+
{
|
|
132
|
+
key: "auctions-show-ended-off-shows-live",
|
|
133
|
+
label: "With \"Show ended\" off (the default), the Auctions listing shows LIVE auctions — not empty, and not requiring the toggle to see anything",
|
|
134
|
+
description: "Fixed 2026-08-20 — the bounded fetch behind the \"unsafe filter\" workaround used to be sorted by the same field the date filter was about to reject on (auctionEndDate ASC = oldest/most-ended first), so once a store accumulated enough already-ended auctions the entire batch could be all-ended and live ones never got fetched at all — you had to turn \"Show ended\" ON to see anything, including live auctions. Load /products?listingType=auction fresh with the toggle off and confirm live auctions appear without touching the toggle.",
|
|
135
|
+
href: "/products",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: "auctions-show-ended-with-nondefault-sort",
|
|
139
|
+
label: "Switching the Auctions sort to something other than \"Ending Soon\" (e.g. \"Highest Current Bid\") while \"Show ended\" stays off still shows live auctions, correctly sorted by the chosen field",
|
|
140
|
+
description: "Same root cause as auctions-show-ended-off-shows-live, but for the case where the sort field doesn't match the date field being filtered on — a separate code path (in-memory re-sort after filtering) that needs its own check.",
|
|
141
|
+
href: "/products",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: "sort-options-per-listing-type",
|
|
145
|
+
label: "Every sort dropdown option (Price, Newest, Ending Soon, Highest/Lowest Bid, Most Bids, Delivery Date, etc.) actually reorders the results on Products, Auctions, and Pre-Orders listing pages",
|
|
146
|
+
href: "/products",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
key: "filter-drawer-combines-correctly",
|
|
150
|
+
label: "Applying multiple filters together (price range + brand + category + condition) narrows results correctly, and clearing filters restores the full list — on Products, Auctions, and Pre-Orders",
|
|
151
|
+
href: "/products",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
key: "search-filter-sort-combo",
|
|
155
|
+
label: "Typing a search query, then applying a filter, then changing sort — all three stay applied together and pagination reflects the combined result count (not just the last action applied)",
|
|
156
|
+
href: "/products",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
key: "listing-toggles-persist-across-pagination",
|
|
160
|
+
label: "Toggling \"Show sold\"/\"Show ended\"/\"Show closed\" and then navigating to page 2 keeps the toggle state — page 2 doesn't silently reset back to hiding those items",
|
|
161
|
+
href: "/products",
|
|
162
|
+
},
|
|
131
163
|
],
|
|
132
164
|
},
|
|
133
165
|
{
|
|
@@ -728,6 +760,12 @@ const rawTesterChecklistItems = [
|
|
|
728
760
|
cases: [
|
|
729
761
|
{ key: "store-directory", label: "The store directory page loads correctly", href: "/stores" },
|
|
730
762
|
{ key: "store-detail-tabs", label: "A store detail page's listing-type dropdown (Products/Auctions/Pre-Orders/Prize Draws/Bundles/Classifieds/Digital Codes/Live Items/Art & Stickers) switches correctly between listing types, and the separate Coupons/Reviews/About tabs next to it all load correctly", description: "The listing-type dropdown is always a dropdown (not just on narrow/mobile widths, unlike category/brand/product/event tabs) since a store can have up to 9 listing types — Coupons, Reviews, and About stay as standalone tabs beside it, never folded into the dropdown." },
|
|
763
|
+
{
|
|
764
|
+
key: "empty-tabs-hidden",
|
|
765
|
+
label: "A store/category/brand detail page never shows a tab for a listing type it has zero items of — e.g. a store with no products doesn't show a \"Products\" tab at all, not an empty products page",
|
|
766
|
+
description: "Fixed 2026-08-20 — tab visibility now checks the already-fetched per-type count and omits the tab entirely when it's zero, instead of always rendering all tabs regardless of whether they'd show anything. \"About\" always stays visible on store pages (no item-count concept). Verify on a real store/category/brand that's genuinely missing at least one listing type.",
|
|
767
|
+
href: "/stores",
|
|
768
|
+
},
|
|
731
769
|
{ key: "sellers-directory", label: "The sellers directory page loads correctly", href: "/sellers" },
|
|
732
770
|
{ key: "seller-detail-page", label: "An individual seller's public detail page loads correctly" },
|
|
733
771
|
{ key: "scams-registry", label: "The scams registry page and an individual scam detail page load correctly", href: "/scams" },
|
package/dist/index.d.ts
CHANGED
|
@@ -1396,7 +1396,9 @@ export { placeBid, buyNowAuction } from "./features/auctions/server";
|
|
|
1396
1396
|
export type { PlaceBidInput, BuyNowAuctionInput, BuyNowAuctionResult } from "./features/auctions/server";
|
|
1397
1397
|
export type { PlaceBidResult } from "./features/auctions/server";
|
|
1398
1398
|
export { CHECKOUT_VALUE_OTP_VERIFY_RATE_LIMIT } from "./features/auth/checkout-value-otp";
|
|
1399
|
+
export { PASSWORD_CHANGE_OTP_VERIFY_RATE_LIMIT } from "./features/auth/password-change-otp";
|
|
1399
1400
|
export { sendCheckoutValueOtp, verifyCheckoutValueOtp, } from "./features/checkout/server";
|
|
1401
|
+
export { sendPasswordChangeOtp, verifyPasswordChangeOtp, isPasswordChangeOtpVerified, consumePasswordChangeOtp, } from "./features/auth/server";
|
|
1400
1402
|
export { authMeGET } from "./features/auth/server";
|
|
1401
1403
|
export { createPasswordResetToken } from "./features/auth/server";
|
|
1402
1404
|
export { createVerificationToken } from "./features/auth/server";
|
package/dist/index.js
CHANGED
|
@@ -2591,12 +2591,16 @@ export { placeBid, buyNowAuction } from "./features/auctions/server";
|
|
|
2591
2591
|
// [SERVER-ONLY]-Server-only â€" uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
|
|
2592
2592
|
// CHECKOUT_VALUE_OTP_VERIFY_RATE_LIMIT - Constant used across modules.
|
|
2593
2593
|
export { CHECKOUT_VALUE_OTP_VERIFY_RATE_LIMIT } from "./features/auth/checkout-value-otp";
|
|
2594
|
+
// PASSWORD_CHANGE_OTP_VERIFY_RATE_LIMIT - Constant used across modules.
|
|
2595
|
+
export { PASSWORD_CHANGE_OTP_VERIFY_RATE_LIMIT } from "./features/auth/password-change-otp";
|
|
2594
2596
|
// [SERVER-ONLY] Tier PP — high-value checkout OTP gate. "use server" actions
|
|
2595
2597
|
// belong in both index.ts and server.ts per the appkit Export Rules table;
|
|
2596
2598
|
// these were only in server.ts, leaving src/actions/checkout.actions.ts's
|
|
2597
2599
|
// `import { sendCheckoutValueOtp, verifyCheckoutValueOtp } from "@mohasinac/appkit"`
|
|
2598
2600
|
// unresolved.
|
|
2599
2601
|
export { sendCheckoutValueOtp, verifyCheckoutValueOtp, } from "./features/checkout/server";
|
|
2602
|
+
// [SERVER-ONLY] Password-change OTP gate — see features/auth/password-change-otp.ts.
|
|
2603
|
+
export { sendPasswordChangeOtp, verifyPasswordChangeOtp, isPasswordChangeOtpVerified, consumePasswordChangeOtp, } from "./features/auth/server";
|
|
2600
2604
|
// [SERVER-ONLY]-Server-only â€" uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
|
|
2601
2605
|
// authMeGET - Shared export for auth me get.
|
|
2602
2606
|
export { authMeGET } from "./features/auth/server";
|
|
@@ -15,6 +15,7 @@ export declare class FirebaseClientAuthProvider implements IClientAuthProvider {
|
|
|
15
15
|
sendPasswordResetEmail(email: string): Promise<void>;
|
|
16
16
|
confirmPasswordReset(code: string, newPassword: string): Promise<void>;
|
|
17
17
|
reauthenticateAndChangePassword(currentPassword: string, newPassword: string): Promise<void>;
|
|
18
|
+
reauthenticateOnly(currentPassword: string): Promise<void>;
|
|
18
19
|
reauthenticateAndSendEmailUpdateVerification(currentPassword: string, newEmail: string): Promise<void>;
|
|
19
20
|
reloadCurrentUser(): Promise<void>;
|
|
20
21
|
}
|
|
@@ -30,6 +30,13 @@ export class FirebaseClientAuthProvider {
|
|
|
30
30
|
await reauthenticateWithCredential(user, credential);
|
|
31
31
|
await updatePassword(user, newPassword);
|
|
32
32
|
}
|
|
33
|
+
async reauthenticateOnly(currentPassword) {
|
|
34
|
+
const user = this._auth.currentUser;
|
|
35
|
+
if (!user?.email)
|
|
36
|
+
throw new Error("No authenticated user.");
|
|
37
|
+
const credential = EmailAuthProvider.credential(user.email, currentPassword);
|
|
38
|
+
await reauthenticateWithCredential(user, credential);
|
|
39
|
+
}
|
|
33
40
|
async reauthenticateAndSendEmailUpdateVerification(currentPassword, newEmail) {
|
|
34
41
|
const user = this._auth.currentUser;
|
|
35
42
|
if (!user?.email)
|
package/dist/server.d.ts
CHANGED
|
@@ -248,6 +248,7 @@ export { listTopLevelCategories } from "./features/categories/server";
|
|
|
248
248
|
export { updateCategory } from "./features/categories/server";
|
|
249
249
|
export { sendCheckoutValueOtp, verifyCheckoutValueOtp, isCheckoutValueOtpVerified, } from "./features/checkout/server";
|
|
250
250
|
export { FailedCheckoutRepository, failedCheckoutRepository } from "./features/checkout/server";
|
|
251
|
+
export { sendPasswordChangeOtp, verifyPasswordChangeOtp, isPasswordChangeOtpVerified, consumePasswordChangeOtp, } from "./features/auth/server";
|
|
251
252
|
export type { FailedCheckoutMeta, FailedPaymentMeta } from "./features/checkout/server";
|
|
252
253
|
export { EmailButton, EmailColumn, EmailContainer, EmailDivider, EmailDoc, EmailFooter, EmailHeader, EmailImage, EmailLink, EmailRow, } from "./features/email";
|
|
253
254
|
export type { EmailButtonProps, EmailColumnProps, EmailContainerProps, EmailDividerProps, EmailDocProps, EmailFooterProps, EmailHeaderProps, EmailImageProps, EmailLinkProps, EmailRowProps, EmailTone, } from "./features/email";
|
package/dist/server.js
CHANGED
|
@@ -715,6 +715,8 @@ export { updateCategory } from "./features/categories/server";
|
|
|
715
715
|
// [SERVER-ONLY] Tier PP — high-value checkout OTP gate.
|
|
716
716
|
export { sendCheckoutValueOtp, verifyCheckoutValueOtp, isCheckoutValueOtpVerified, } from "./features/checkout/server";
|
|
717
717
|
export { FailedCheckoutRepository, failedCheckoutRepository } from "./features/checkout/server";
|
|
718
|
+
// [SERVER-ONLY] Password-change OTP gate — see features/auth/password-change-otp.ts.
|
|
719
|
+
export { sendPasswordChangeOtp, verifyPasswordChangeOtp, isPasswordChangeOtpVerified, consumePasswordChangeOtp, } from "./features/auth/server";
|
|
718
720
|
// [SERVER-ONLY] Email primitives — table-based, inline-styled components
|
|
719
721
|
// that render email-client-compatible HTML via renderToStaticMarkup. Use
|
|
720
722
|
// these instead of authoring raw <table>/<tr>/<td> in email templates.
|
|
@@ -114,17 +114,17 @@ export declare const changePasswordSchema: z.ZodEffects<z.ZodObject<{
|
|
|
114
114
|
currentPassword: z.ZodString;
|
|
115
115
|
newPassword: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
|
|
116
116
|
}, "strip", z.ZodTypeAny, {
|
|
117
|
-
newPassword: string;
|
|
118
117
|
currentPassword: string;
|
|
119
|
-
}, {
|
|
120
118
|
newPassword: string;
|
|
119
|
+
}, {
|
|
121
120
|
currentPassword: string;
|
|
122
|
-
}>, {
|
|
123
121
|
newPassword: string;
|
|
122
|
+
}>, {
|
|
124
123
|
currentPassword: string;
|
|
125
|
-
}, {
|
|
126
124
|
newPassword: string;
|
|
125
|
+
}, {
|
|
127
126
|
currentPassword: string;
|
|
127
|
+
newPassword: string;
|
|
128
128
|
}>;
|
|
129
129
|
export declare const cropDataSchema: z.ZodEffects<z.ZodEffects<z.ZodEffects<z.ZodObject<{
|
|
130
130
|
sourceUrl: z.ZodEffects<z.ZodString, string, string>;
|