From 6390749150ecb9e0fa749d09abdfd280748053f6 Mon Sep 17 00:00:00 2001 From: barsa Date: Sat, 27 Sep 2025 18:16:35 +0900 Subject: [PATCH] Refactor authentication components to improve loading state management and user experience. Update Button component to utilize a Spinner for loading indicators, enhancing visual feedback during form submissions. Integrate LoadingOverlay in LoginView and SignupView for a full-page loading experience during authentication processes. Adjust useAuthStore to manage loading states more effectively, ensuring a smoother user experience during redirects. --- .../guards/failed-login-throttle.guard.ts | 2 +- .../src/components/atoms/LoadingOverlay.tsx | 41 ++++++++++++++++++ apps/portal/src/components/atoms/Spinner.tsx | 42 ++++++++++++++++++ apps/portal/src/components/atoms/button.tsx | 24 ++++------- apps/portal/src/components/atoms/index.ts | 4 +- .../LinkWhmcsForm/LinkWhmcsForm.tsx | 17 ++++---- .../auth/components/LoginForm/LoginForm.tsx | 17 ++++---- .../src/features/auth/hooks/use-auth.ts | 4 ++ .../src/features/auth/services/auth.store.ts | 9 ++-- .../src/features/auth/views/LoginView.tsx | 19 ++++++-- .../features/auth/views/SetPasswordView.tsx | 10 +++-- .../src/features/auth/views/SignupView.tsx | 43 ++++++++++++------- .../dashboard/hooks/useDashboardSummary.ts | 2 +- .../dashboard/views/DashboardView.tsx | 9 +++- .../subscriptions/hooks/useSubscriptions.ts | 4 +- 15 files changed, 182 insertions(+), 65 deletions(-) create mode 100644 apps/portal/src/components/atoms/LoadingOverlay.tsx create mode 100644 apps/portal/src/components/atoms/Spinner.tsx diff --git a/apps/bff/src/modules/auth/guards/failed-login-throttle.guard.ts b/apps/bff/src/modules/auth/guards/failed-login-throttle.guard.ts index baf5cc26..284c2f45 100644 --- a/apps/bff/src/modules/auth/guards/failed-login-throttle.guard.ts +++ b/apps/bff/src/modules/auth/guards/failed-login-throttle.guard.ts @@ -6,7 +6,7 @@ import type { Request } from "express"; @Injectable() export class FailedLoginThrottleGuard { - constructor(@Inject("REDIS") private readonly redis: Redis) {} + constructor(@Inject("REDIS_CLIENT") private readonly redis: Redis) {} private getTracker(req: Request): string { // Track by IP address + User Agent for failed login attempts only diff --git a/apps/portal/src/components/atoms/LoadingOverlay.tsx b/apps/portal/src/components/atoms/LoadingOverlay.tsx new file mode 100644 index 00000000..592f81d2 --- /dev/null +++ b/apps/portal/src/components/atoms/LoadingOverlay.tsx @@ -0,0 +1,41 @@ +import { Spinner } from "./Spinner"; + +interface LoadingOverlayProps { + /** Whether the overlay is visible */ + isVisible: boolean; + /** Main loading message */ + title: string; + /** Optional subtitle/description */ + subtitle?: string; + /** Spinner size */ + spinnerSize?: "xs" | "sm" | "md" | "lg" | "xl"; + /** Custom spinner color */ + spinnerClassName?: string; + /** Custom overlay background */ + overlayClassName?: string; +} + +export function LoadingOverlay({ + isVisible, + title, + subtitle, + spinnerSize = "xl", + spinnerClassName = "text-blue-600", + overlayClassName = "bg-white/80 backdrop-blur-sm", +}: LoadingOverlayProps) { + if (!isVisible) { + return null; + } + + return ( +
+
+ +

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ ); +} diff --git a/apps/portal/src/components/atoms/Spinner.tsx b/apps/portal/src/components/atoms/Spinner.tsx new file mode 100644 index 00000000..604e483a --- /dev/null +++ b/apps/portal/src/components/atoms/Spinner.tsx @@ -0,0 +1,42 @@ +import { cn } from "@/lib/utils"; + +interface SpinnerProps { + size?: "xs" | "sm" | "md" | "lg" | "xl"; + className?: string; +} + +const sizeClasses = { + xs: "h-3 w-3", + sm: "h-4 w-4", + md: "h-6 w-6", + lg: "h-8 w-8", + xl: "h-10 w-10", +}; + +export function Spinner({ size = "sm", className }: SpinnerProps) { + return ( + + + + + ); +} diff --git a/apps/portal/src/components/atoms/button.tsx b/apps/portal/src/components/atoms/button.tsx index cb3bcde1..c6474aa2 100644 --- a/apps/portal/src/components/atoms/button.tsx +++ b/apps/portal/src/components/atoms/button.tsx @@ -2,7 +2,7 @@ import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "reac import { forwardRef } from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; -// Loading spinner removed - using inline spinner for buttons +import { Spinner } from "./Spinner"; const buttonVariants = cva( "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", @@ -75,15 +75,11 @@ const Button = forwardRef((p aria-busy={loading || undefined} {...anchorProps} > - - {loading ? ( -
- ) : ( - leftIcon - )} - {loading ? (loadingText ?? children) : children} - {!loading && rightIcon ? {rightIcon} : null} - + + {loading ? : leftIcon} + {loading ? (loadingText ?? children) : children} + {!loading && rightIcon ? {rightIcon} : null} + ); } @@ -104,12 +100,8 @@ const Button = forwardRef((p aria-busy={loading || undefined} {...buttonProps} > - - {loading ? ( -
- ) : ( - leftIcon - )} + + {loading ? : leftIcon} {loading ? (loadingText ?? children) : children} {!loading && rightIcon ? {rightIcon} : null} diff --git a/apps/portal/src/components/atoms/index.ts b/apps/portal/src/components/atoms/index.ts index 60cc1587..6a2c2df0 100644 --- a/apps/portal/src/components/atoms/index.ts +++ b/apps/portal/src/components/atoms/index.ts @@ -26,7 +26,9 @@ export type { StatusPillProps } from "./status-pill"; export { Badge, badgeVariants } from "./badge"; export type { BadgeProps } from "./badge"; -// Loading components consolidated into skeleton loading +// Loading components +export { Spinner } from "./Spinner"; +export { LoadingOverlay } from "./LoadingOverlay"; export { ErrorState, diff --git a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx index 7317d7ce..d9c25268 100644 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx +++ b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx @@ -76,15 +76,14 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr {error && {error}} - diff --git a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx index ee12a338..5cecb9cc 100644 --- a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx +++ b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx @@ -121,15 +121,14 @@ export function LoginForm({ {error && {error}} - {showSignupLink && ( diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts index 7802f429..5940bfc5 100644 --- a/apps/portal/src/features/auth/hooks/use-auth.ts +++ b/apps/portal/src/features/auth/hooks/use-auth.ts @@ -35,14 +35,17 @@ export function useAuth() { checkAuth, refreshSession, clearError, + clearLoading, } = useAuthStore(); // Enhanced login with redirect handling const login = useCallback( async (credentials: LoginRequestInput) => { await loginAction(credentials); + // Keep loading state active during redirect const redirectTo = getPostLoginRedirect(searchParams); router.push(redirectTo); + // Note: loading will be cleared when the new page loads }, [loginAction, router, searchParams] ); @@ -84,6 +87,7 @@ export function useAuth() { checkAuth, refreshSession, clearError, + clearLoading, }; } diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 99687264..8e5a14db 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -44,6 +44,7 @@ export interface AuthState { refreshSession: () => Promise; checkAuth: () => Promise; clearError: () => void; + clearLoading: () => void; hydrateUserProfile: (profile: Partial) => void; } @@ -53,7 +54,7 @@ type AuthResponseData = { }; export const useAuthStore = create()((set, get) => { - const applyAuthResponse = (data: AuthResponseData) => { + const applyAuthResponse = (data: AuthResponseData, keepLoading = false) => { set({ user: data.user, session: { @@ -61,7 +62,7 @@ export const useAuthStore = create()((set, get) => { refreshExpiresAt: data.tokens.refreshExpiresAt, }, isAuthenticated: true, - loading: false, + loading: keepLoading, error: null, }); }; @@ -82,7 +83,7 @@ export const useAuthStore = create()((set, get) => { if (!parsed.success) { throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed"); } - applyAuthResponse(parsed.data); + applyAuthResponse(parsed.data, true); // Keep loading for redirect } catch (error) { const errorInfo = getErrorInfo(error); set({ loading: false, error: errorInfo.message, isAuthenticated: false }); @@ -302,6 +303,8 @@ export const useAuthStore = create()((set, get) => { clearError: () => set({ error: null }), + clearLoading: () => set({ loading: false }), + hydrateUserProfile: profile => { set(state => { if (!state.user) { diff --git a/apps/portal/src/features/auth/views/LoginView.tsx b/apps/portal/src/features/auth/views/LoginView.tsx index 2d34f608..03e405fd 100644 --- a/apps/portal/src/features/auth/views/LoginView.tsx +++ b/apps/portal/src/features/auth/views/LoginView.tsx @@ -2,12 +2,25 @@ import { AuthLayout } from "../components"; import { LoginForm } from "@/features/auth/components"; +import { useAuthStore } from "../services/auth.store"; +import { LoadingOverlay } from "@/components/atoms"; export function LoginView() { + const { loading, isAuthenticated } = useAuthStore(); + return ( - - - + <> + + + + + {/* Full-page loading overlay during authentication */} + + ); } diff --git a/apps/portal/src/features/auth/views/SetPasswordView.tsx b/apps/portal/src/features/auth/views/SetPasswordView.tsx index b39a1bd3..43519b5a 100644 --- a/apps/portal/src/features/auth/views/SetPasswordView.tsx +++ b/apps/portal/src/features/auth/views/SetPasswordView.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { AuthLayout } from "../components"; import { SetPasswordForm } from "@/features/auth/components"; +import { LoadingOverlay } from "@/components/atoms"; function SetPasswordContent() { const router = useRouter(); @@ -87,9 +88,12 @@ export function SetPasswordView() { -
-
-
+ } > diff --git a/apps/portal/src/features/auth/views/SignupView.tsx b/apps/portal/src/features/auth/views/SignupView.tsx index a5f52d90..5d4810c8 100644 --- a/apps/portal/src/features/auth/views/SignupView.tsx +++ b/apps/portal/src/features/auth/views/SignupView.tsx @@ -2,25 +2,38 @@ import { AuthLayout } from "../components"; import { SignupForm } from "@/features/auth/components"; +import { useAuthStore } from "../services/auth.store"; +import { LoadingOverlay } from "@/components/atoms"; export function SignupView() { + const { loading, isAuthenticated } = useAuthStore(); + return ( - -
-
-

What you'll need

-
    -
  • Your Assist Solutions customer number
  • -
  • Primary contact details and service address
  • -
  • A secure password that meets our enhanced requirements
  • -
+ <> + +
+
+

What you'll need

+
    +
  • Your Assist Solutions customer number
  • +
  • Primary contact details and service address
  • +
  • A secure password that meets our enhanced requirements
  • +
+
+
- -
- + + + {/* Full-page loading overlay during authentication */} + + ); } diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts index 0e45dd6b..fccc0f44 100644 --- a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts +++ b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts @@ -36,7 +36,7 @@ export function useDashboardSummary() { } try { - const response = await apiClient.GET("/api/users/summary"); + const response = await apiClient.GET("/api/me/summary"); return getDataOrThrow(response, "Dashboard summary response was empty"); } catch (error) { // Transform API errors to DashboardError format diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index 32ad3299..8203c862 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import type { Activity, DashboardSummary } from "@customer-portal/domain"; @@ -31,7 +31,12 @@ import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling"; export function DashboardView() { const router = useRouter(); - const { user, isAuthenticated, loading: authLoading } = useAuthStore(); + const { user, isAuthenticated, loading: authLoading, clearLoading } = useAuthStore(); + + // Clear auth loading state when dashboard loads (after successful login) + useEffect(() => { + clearLoading(); + }, [clearLoading]); const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary(); const upcomingInvoice = summary?.nextInvoice ?? null; const createSsoLinkMutation = useCreateInvoiceSsoLink(); diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index acaf56aa..9df7ad14 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -58,7 +58,7 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { queryKey: queryKeys.subscriptions.list(status ? { status } : undefined), queryFn: async () => { const response = await apiClient.GET( - "/subscriptions", + "/api/subscriptions", status ? { params: { query: { status } } } : undefined ); return toSubscriptionList(getNullableData(response)); @@ -96,7 +96,7 @@ export function useSubscriptionStats() { return useQuery({ queryKey: queryKeys.subscriptions.stats(), queryFn: async () => { - const response = await apiClient.GET("/subscriptions/stats"); + const response = await apiClient.GET("/api/subscriptions/stats"); return getDataOrDefault(response, emptyStats); }, staleTime: 5 * 60 * 1000,