diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts
index b2cf2df7..36e4a3d3 100644
--- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts
+++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts
@@ -74,6 +74,8 @@ class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededRes
class LoginVerifyOtpRequestDto extends createZodDto(loginVerifyOtpRequestSchema) {}
@Controller("auth")
+@UseGuards(RateLimitGuard)
+@RateLimit({ limit: 10, ttl: 300 }) // 10 requests per 5 minutes per IP+UA (controller default)
export class AuthController {
constructor(
private authOrchestrator: AuthOrchestrator,
@@ -181,8 +183,6 @@ export class AuthController {
*/
@Public()
@Post("login/verify-otp")
- @UseGuards(RateLimitGuard)
- @RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
@HttpCode(200)
async verifyLoginOtp(
@Body() body: LoginVerifyOtpRequestDto,
@@ -260,8 +260,6 @@ export class AuthController {
@Public()
@Post("refresh")
- @UseGuards(RateLimitGuard)
- @RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
async refreshToken(
@Body() body: RefreshTokenRequestDto,
@Req() req: RequestWithCookies,
@@ -280,8 +278,6 @@ export class AuthController {
@Public()
@Post("set-password")
- @UseGuards(RateLimitGuard)
- @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard)
async setPassword(
@Body() setPasswordData: SetPasswordRequestDto,
@Req() _req: Request,
@@ -309,8 +305,6 @@ export class AuthController {
@Public()
@Post("request-password-reset")
- @UseGuards(RateLimitGuard)
- @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) {
await this.passwordWorkflow.requestPasswordReset(body.email, req);
return { message: "If an account exists, a reset email has been sent" };
@@ -319,8 +313,6 @@ export class AuthController {
@Public()
@Post("reset-password")
@HttpCode(200)
- @UseGuards(RateLimitGuard)
- @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
async resetPassword(
@Body() body: ResetPasswordRequestDto,
@Res({ passthrough: true }) res: Response
@@ -333,8 +325,6 @@ export class AuthController {
}
@Post("change-password")
- @UseGuards(RateLimitGuard)
- @RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes
async changePassword(
@Req() req: Request & { user: { id: string } },
@Body() body: ChangePasswordRequestDto,
diff --git a/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx b/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx
index 8cef35a8..a57cc60d 100644
--- a/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx
+++ b/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx
@@ -65,7 +65,7 @@ function useOtpHandlers({
const newValue = newDigits.join("");
onChange(newValue);
if (char && index < length - 1) focusInput(index + 1);
- if (newValue.length === length && !newValue.includes("")) onComplete?.(newValue);
+ if (newValue.length === length) onComplete?.(newValue);
},
[digits, length, onChange, onComplete, focusInput]
);
diff --git a/apps/portal/src/components/templates/PageLayout/PageLayout.tsx b/apps/portal/src/components/templates/PageLayout/PageLayout.tsx
index fc00c487..953c5dee 100644
--- a/apps/portal/src/components/templates/PageLayout/PageLayout.tsx
+++ b/apps/portal/src/components/templates/PageLayout/PageLayout.tsx
@@ -5,6 +5,7 @@ import { Skeleton } from "@/components/atoms/loading-skeleton";
import { ErrorState } from "@/components/atoms/error-state";
interface PageLayoutProps {
+ /** @deprecated Icons no longer rendered in page headers */
icon?: ReactNode | undefined;
title: string;
description?: string | undefined;
@@ -19,7 +20,6 @@ interface PageLayoutProps {
}
export function PageLayout({
- icon,
title,
description,
actions,
@@ -47,8 +47,7 @@ export function PageLayout({
{backLink.label}
)}
- {icon &&
{icon}
}
- {title}
+ {title}
{statusPill && {statusPill}
}
{description && (
diff --git a/apps/portal/src/features/auth/views/ForgotPasswordView.tsx b/apps/portal/src/features/auth/views/ForgotPasswordView.tsx
index b63eabcc..d5556ee7 100644
--- a/apps/portal/src/features/auth/views/ForgotPasswordView.tsx
+++ b/apps/portal/src/features/auth/views/ForgotPasswordView.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
-import { Mail } from "lucide-react";
+import { Mail, ArrowLeft, Clock, CheckCircle2 } from "lucide-react";
import { AuthLayout } from "../components";
import { PasswordResetForm } from "@/features/auth/components";
import { Button, AnimatedContainer } from "@/components/atoms";
@@ -10,12 +10,22 @@ import { useAuthStore } from "../stores/auth.store";
const RESEND_COOLDOWN_SECONDS = 60;
-function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm: () => void }) {
- const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS);
+function EmailSentConfirmation({
+ email,
+ cooldownEndRef,
+ onBackToForm,
+}: {
+ email: string;
+ cooldownEndRef: React.RefObject;
+ onBackToForm: () => void;
+}) {
+ const [secondsLeft, setSecondsLeft] = useState(() =>
+ Math.max(0, Math.ceil((cooldownEndRef.current - Date.now()) / 1000))
+ );
const [resending, setResending] = useState(false);
const [resendError, setResendError] = useState(null);
+ const [resendCount, setResendCount] = useState(0);
const requestPasswordReset = useAuthStore(state => state.requestPasswordReset);
- const cooldownEndRef = useRef(Date.now() + RESEND_COOLDOWN_SECONDS * 1000);
useEffect(() => {
const update = () => {
@@ -25,7 +35,7 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
- }, []);
+ }, [cooldownEndRef]);
const handleResend = useCallback(async () => {
setResending(true);
@@ -34,30 +44,44 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
await requestPasswordReset(email);
cooldownEndRef.current = Date.now() + RESEND_COOLDOWN_SECONDS * 1000;
setSecondsLeft(RESEND_COOLDOWN_SECONDS);
+ setResendCount(c => c + 1);
} catch {
setResendError("Failed to resend. Please try again.");
} finally {
setResending(false);
}
- }, [email, requestPasswordReset]);
+ }, [email, requestPasswordReset, cooldownEndRef]);
const canResend = secondsLeft === 0 && !resending;
return (
-
-
-
-
-
-
-
Check your email
+
+
+
+
+
+
Check your email
We sent a password reset link to{" "}
{email}
+
+
+
+
+
Click the link in the email to reset your password.
+
The link expires in 15 minutes.
+
+
+
+
+ {resendCount > 0 && (
+
Reset link resent successfully
+ )}
+
{canResend ? (
-
+
Back to login
+
+
+ Didn't receive the email? Check your spam folder or try resending.
+
);
@@ -98,11 +130,24 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
export function ForgotPasswordView() {
const [sentToEmail, setSentToEmail] = useState
(null);
+ // Cooldown persists across form/confirmation transitions — prevents bypass
+ const cooldownEndRef = useRef(0);
+
+ const handleSuccess = useCallback((email?: string) => {
+ if (email) {
+ cooldownEndRef.current = Date.now() + RESEND_COOLDOWN_SECONDS * 1000;
+ setSentToEmail(email);
+ }
+ }, []);
if (sentToEmail) {
return (
- setSentToEmail(null)} />
+ setSentToEmail(null)}
+ />
);
}
@@ -112,12 +157,7 @@ export function ForgotPasswordView() {
title="Forgot password"
subtitle="Enter your email address and we'll send you a reset link"
>
- {
- if (email) setSentToEmail(email);
- }}
- />
+
);
}
diff --git a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts
index 991d7e79..70e515c0 100644
--- a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts
+++ b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts
@@ -8,21 +8,15 @@ import { useAuthSession } from "@/features/auth/stores/auth.store";
type Tone = "info" | "success" | "warning" | "error";
interface UsePaymentRefreshOptions {
- // Refetch function from usePaymentMethods
refetch: () => Promise<{ data: PaymentMethodList | undefined }>;
- // When true, attaches focus/visibility listeners to refresh automatically
- attachFocusListeners?: boolean;
- // Optional custom detector for whether payment methods exist
hasMethods?: (data?: PaymentMethodList) => boolean;
}
-export function usePaymentRefresh({
- refetch,
- attachFocusListeners = false,
- hasMethods,
-}: UsePaymentRefreshOptions) {
+export function usePaymentRefresh({ refetch, hasMethods }: UsePaymentRefreshOptions) {
const { isAuthenticated } = useAuthSession();
const hideToastTimeout = useRef(null);
+ const lastRefreshAt = useRef(0);
+ const [isRefreshing, setIsRefreshing] = useState(false);
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false,
text: "",
@@ -36,19 +30,20 @@ export function usePaymentRefresh({
}
};
- const triggerRefresh = useCallback(async () => {
- // Don't trigger refresh if not authenticated
- if (!isAuthenticated) {
- return;
- }
+ const COOLDOWN_MS = 10_000;
+ const triggerRefresh = useCallback(async () => {
+ const now = Date.now();
+ if (!isAuthenticated || isRefreshing || now - lastRefreshAt.current < COOLDOWN_MS) return;
+
+ lastRefreshAt.current = now;
+ setIsRefreshing(true);
setToast({ visible: true, text: "Refreshing payment methods...", tone: "info" });
try {
try {
await apiClient.POST("/api/invoices/payment-methods/refresh");
} catch {
// Soft-fail cache refresh, still attempt refetch
- // Payment methods cache refresh failed - silently continue
}
const result = await refetch();
const parsed = paymentMethodListSchema.safeParse(result.data ?? null);
@@ -65,13 +60,14 @@ export function usePaymentRefresh({
} catch {
setToast({ visible: true, text: "Could not refresh payment methods", tone: "warning" });
} finally {
+ setIsRefreshing(false);
clearHideToastTimeout();
hideToastTimeout.current = window.setTimeout(() => {
setToast(t => ({ ...t, visible: false }));
hideToastTimeout.current = null;
}, 2200);
}
- }, [isAuthenticated, refetch, hasMethods]);
+ }, [isAuthenticated, isRefreshing, refetch, hasMethods]);
useEffect(() => {
return () => {
@@ -79,22 +75,5 @@ export function usePaymentRefresh({
};
}, []);
- useEffect(() => {
- if (!attachFocusListeners) return;
-
- const onFocus = () => {
- void triggerRefresh();
- };
- const onVis = () => {
- if (document.visibilityState === "visible") void triggerRefresh();
- };
- window.addEventListener("focus", onFocus);
- document.addEventListener("visibilitychange", onVis);
- return () => {
- window.removeEventListener("focus", onFocus);
- document.removeEventListener("visibilitychange", onVis);
- };
- }, [attachFocusListeners, triggerRefresh]);
-
- return { toast, triggerRefresh, setToast } as const;
+ return { toast, triggerRefresh, isRefreshing, setToast } as const;
}
diff --git a/apps/portal/src/features/billing/views/BillingOverview.tsx b/apps/portal/src/features/billing/views/BillingOverview.tsx
index 17c9b0fb..b9e0ebac 100644
--- a/apps/portal/src/features/billing/views/BillingOverview.tsx
+++ b/apps/portal/src/features/billing/views/BillingOverview.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
-import { CreditCardIcon } from "@heroicons/react/24/outline";
+import { CreditCardIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { PageLayout } from "@/components/templates/PageLayout";
import { ErrorBoundary } from "@/components/molecules";
import { useSession } from "@/features/auth/hooks";
@@ -52,10 +52,14 @@ function PaymentMethodsSection({
paymentMethodsData,
onManage,
isPending,
+ onRefresh,
+ isRefreshing,
}: {
paymentMethodsData: PaymentMethodList;
onManage: () => void;
isPending: boolean;
+ onRefresh: () => void;
+ isRefreshing: boolean;
}) {
const hasMethods = paymentMethodsData.paymentMethods.length > 0;
@@ -71,9 +75,15 @@ function PaymentMethodsSection({
: "No payment methods on file"}
-
+
+
+
+
{hasMethods && (
@@ -108,7 +118,6 @@ export function BillingOverview() {
return { data: result.data };
},
hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
- attachFocusListeners: true,
});
const openPaymentMethods = async () => {
@@ -161,6 +170,8 @@ export function BillingOverview() {
paymentMethodsData={paymentMethodsData}
onManage={() => void openPaymentMethods()}
isPending={createPaymentMethodsSsoLink.isPending}
+ onRefresh={() => void paymentRefresh.triggerRefresh()}
+ isRefreshing={paymentRefresh.isRefreshing}
/>
)}
diff --git a/apps/portal/src/features/billing/views/PaymentMethods.tsx b/apps/portal/src/features/billing/views/PaymentMethods.tsx
index 3febb59d..c0f33a46 100644
--- a/apps/portal/src/features/billing/views/PaymentMethods.tsx
+++ b/apps/portal/src/features/billing/views/PaymentMethods.tsx
@@ -15,7 +15,7 @@ import {
useCreatePaymentMethodsSsoLink,
} from "@/features/billing";
import type { PaymentMethodList } from "@customer-portal/domain/payments";
-import { CreditCardIcon } from "@heroicons/react/24/outline";
+import { CreditCardIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { InlineToast } from "@/components/atoms/inline-toast";
import { Button } from "@/components/atoms/button";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
@@ -54,6 +54,15 @@ function PaymentMethodsSkeleton() {
);
}
+function RefreshButton({ onClick, isRefreshing }: { onClick: () => void; isRefreshing: boolean }) {
+ return (
+
+ );
+}
+
function ManageCardsButton({ onClick, isPending }: { onClick: () => void; isPending: boolean }) {
return (