@mohasinac/appkit 4.5.3 → 4.6.1

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 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 */
@@ -1,2 +1,3 @@
1
1
  export * from "./profile-actions";
2
2
  export * from "./realtime-token-actions";
3
+ export * from "./password-change-otp-actions";
@@ -1,2 +1,3 @@
1
1
  export * from "./profile-actions";
2
2
  export * from "./realtime-token-actions";
3
+ export * from "./password-change-otp-actions";
@@ -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
- export function useChangePassword(options) {
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 (data) => {
359
- await getClientAuthProvider().reauthenticateAndChangePassword(data.currentPassword, data.newPassword);
360
- return apiClient.post(ACCOUNT_ENDPOINTS.CHANGE_PASSWORD, data);
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
+ }
@@ -71,6 +71,12 @@ const rawTesterChecklistItems = [
71
71
  { key: "avatar-upload", label: "Uploading a profile avatar works" },
72
72
  { key: "notification-prefs", label: "Notification preferences save correctly" },
73
73
  { key: "public-profile-toggle", label: "Public profile visibility toggle works" },
74
+ {
75
+ key: "password-change-otp-required",
76
+ label: "Changing your password from Settings requires entering an emailed 6-digit code before it actually takes effect — the password does not change just from submitting the current/new password form",
77
+ description: "Fixed 2026-08-20 — the password-change API previously trusted the session cookie alone, with no server-side check of the current password at all. Submit the Change Password form with a correct current password; confirm you land on a \"we sent a code to your email\" step, and the password only actually updates after entering the correct code. Also verify: an expired/wrong code is rejected with a clear error, and \"Resend code\" works.",
78
+ href: "/user/settings",
79
+ },
74
80
  {
75
81
  key: "own-public-profile-quick-links",
76
82
  label: "\"View public profile\" is easy to find and works from three places: the My Account dashboard header, the My Account quick-links grid (\"My Public Profile\" tile), and the /user/profile page (next to \"Manage Addresses\")",
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>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.5.3",
3
+ "version": "4.6.1",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"