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 ( +
+ + +

Opens in a new tab for security

@@ -213,7 +233,6 @@ export function PaymentMethodsContainer() { return { data: result.data }; }, hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)), - attachFocusListeners: true, }); const openPaymentMethods = async () => { @@ -265,12 +284,16 @@ export function PaymentMethodsContainer() { paymentMethodsData={paymentMethodsData} onManage={handleManage} isPending={createPaymentMethodsSsoLink.isPending} + onRefresh={() => void paymentRefresh.triggerRefresh()} + isRefreshing={paymentRefresh.isRefreshing} /> )} {!isDataLoading && !hasMethods && ( void paymentRefresh.triggerRefresh()} + isRefreshing={paymentRefresh.isRefreshing} /> )} diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx index 25d4f1cf..8c716fa9 100644 --- a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx @@ -54,7 +54,7 @@ function hasCompleteAddress(address: Record | null | undefined) function useCheckoutPayment() { const { data: paymentMethods, isLoading, error, refetch } = usePaymentMethods(); - const paymentRefresh = usePaymentRefresh({ refetch, attachFocusListeners: false }); + const paymentRefresh = usePaymentRefresh({ refetch }); const { showToast } = useCheckoutToast({ setToast: paymentRefresh.setToast }); const list = paymentMethods?.paymentMethods ?? []; diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx index ef7df58c..ae558b7e 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx @@ -24,6 +24,12 @@ export function VerificationStep() { setCode(value); }; + const handleComplete = (completedCode: string) => { + if (!loading) { + send({ type: "VERIFY_CODE", code: completedCode }); + } + }; + const handleVerify = () => { if (code.length === 6) { send({ type: "VERIFY_CODE", code }); @@ -53,6 +59,7 @@ export function VerificationStep() {
-
- - ¥{tier.monthlyPrice.toLocaleString()} - {tier.maxMonthlyPrice && - tier.maxMonthlyPrice > tier.monthlyPrice && - `~${tier.maxMonthlyPrice.toLocaleString()}`} - - /mo -
+ + ¥{tier.monthlyPrice.toLocaleString()} + {tier.maxMonthlyPrice && + tier.maxMonthlyPrice > tier.monthlyPrice && + `~${tier.maxMonthlyPrice.toLocaleString()}`} + + /mo {tier.pricingNote && ( diff --git a/apps/portal/src/features/services/components/internet/image.png b/apps/portal/src/features/services/components/internet/image.png new file mode 100644 index 00000000..0e354b69 Binary files /dev/null and b/apps/portal/src/features/services/components/internet/image.png differ diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index f1d4ce5e..3bc3f6b9 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -182,7 +182,7 @@ function TierCard({ tier }: { tier: TierInfo }) { return (
@@ -194,25 +194,15 @@ function TierCard({ tier }: { tier: TierInfo }) {
)} -

- {tier.tier} -

-
-
- - ¥{tier.monthlyPrice.toLocaleString()} - {tier.maxMonthlyPrice && - tier.maxMonthlyPrice > tier.monthlyPrice && - `~${tier.maxMonthlyPrice.toLocaleString()}`} - - /mo -
+

{tier.tier}

+
+ + ¥{tier.monthlyPrice.toLocaleString()} + {tier.maxMonthlyPrice && + tier.maxMonthlyPrice > tier.monthlyPrice && + `~${tier.maxMonthlyPrice.toLocaleString()}`} + + /mo {tier.pricingNote && ( {tier.pricingNote} )} @@ -325,7 +315,7 @@ function UnifiedInternetCard({ variants={tierContainerVariants} > {displayTiers.map(tier => ( - + ))} diff --git a/image.png b/image.png index be91fcee..0e354b69 100644 Binary files a/image.png and b/image.png differ