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.

This commit is contained in:
barsa 2025-09-27 18:16:35 +09:00
parent 2b54773ebf
commit 6390749150
15 changed files with 182 additions and 65 deletions

View File

@ -6,7 +6,7 @@ import type { Request } from "express";
@Injectable() @Injectable()
export class FailedLoginThrottleGuard { export class FailedLoginThrottleGuard {
constructor(@Inject("REDIS") private readonly redis: Redis) {} constructor(@Inject("REDIS_CLIENT") private readonly redis: Redis) {}
private getTracker(req: Request): string { private getTracker(req: Request): string {
// Track by IP address + User Agent for failed login attempts only // Track by IP address + User Agent for failed login attempts only

View File

@ -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 (
<div className={`fixed inset-0 z-50 flex items-center justify-center ${overlayClassName}`}>
<div className="text-center">
<Spinner size={spinnerSize} className={`mb-4 ${spinnerClassName}`} />
<p className="text-lg font-medium text-gray-900">{title}</p>
{subtitle && (
<p className="text-sm text-gray-600 mt-1">{subtitle}</p>
)}
</div>
</div>
);
}

View File

@ -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 (
<svg
className={cn(
"animate-spin text-current",
sizeClasses[size],
className
)}
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

View File

@ -2,7 +2,7 @@ import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "reac
import { forwardRef } from "react"; import { forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// Loading spinner removed - using inline spinner for buttons import { Spinner } from "./Spinner";
const buttonVariants = cva( 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", "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<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
aria-busy={loading || undefined} aria-busy={loading || undefined}
{...anchorProps} {...anchorProps}
> >
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center justify-center gap-2">
{loading ? ( {loading ? <Spinner size="sm" /> : leftIcon}
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" /> <span>{loading ? (loadingText ?? children) : children}</span>
) : ( {!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
leftIcon </span>
)}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span>
</a> </a>
); );
} }
@ -104,12 +100,8 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
aria-busy={loading || undefined} aria-busy={loading || undefined}
{...buttonProps} {...buttonProps}
> >
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center justify-center gap-2">
{loading ? ( {loading ? <Spinner size="sm" /> : leftIcon}
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
) : (
leftIcon
)}
<span>{loading ? (loadingText ?? children) : children}</span> <span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null} {!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span> </span>

View File

@ -26,7 +26,9 @@ export type { StatusPillProps } from "./status-pill";
export { Badge, badgeVariants } from "./badge"; export { Badge, badgeVariants } from "./badge";
export type { BadgeProps } from "./badge"; export type { BadgeProps } from "./badge";
// Loading components consolidated into skeleton loading // Loading components
export { Spinner } from "./Spinner";
export { LoadingOverlay } from "./LoadingOverlay";
export { export {
ErrorState, ErrorState,

View File

@ -76,15 +76,14 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>} {error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
<Button type="submit" disabled={isSubmitting || loading} className="w-full"> <Button
{isSubmitting || loading ? ( type="submit"
<> disabled={isSubmitting || loading}
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> loading={isSubmitting || loading}
Linking Account... loadingText="Linking Account..."
</> className="w-full"
) : ( >
"Link WHMCS Account" Link WHMCS Account
)}
</Button> </Button>
</form> </form>

View File

@ -121,15 +121,14 @@ export function LoginForm({
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>} {error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
<Button type="submit" disabled={isSubmitting || loading} className="w-full"> <Button
{isSubmitting || loading ? ( type="submit"
<> disabled={isSubmitting || loading}
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> loading={isSubmitting || loading}
Signing in... loadingText="Signing in..."
</> className="w-full"
) : ( >
"Sign in" Sign in
)}
</Button> </Button>
{showSignupLink && ( {showSignupLink && (

View File

@ -35,14 +35,17 @@ export function useAuth() {
checkAuth, checkAuth,
refreshSession, refreshSession,
clearError, clearError,
clearLoading,
} = useAuthStore(); } = useAuthStore();
// Enhanced login with redirect handling // Enhanced login with redirect handling
const login = useCallback( const login = useCallback(
async (credentials: LoginRequestInput) => { async (credentials: LoginRequestInput) => {
await loginAction(credentials); await loginAction(credentials);
// Keep loading state active during redirect
const redirectTo = getPostLoginRedirect(searchParams); const redirectTo = getPostLoginRedirect(searchParams);
router.push(redirectTo); router.push(redirectTo);
// Note: loading will be cleared when the new page loads
}, },
[loginAction, router, searchParams] [loginAction, router, searchParams]
); );
@ -84,6 +87,7 @@ export function useAuth() {
checkAuth, checkAuth,
refreshSession, refreshSession,
clearError, clearError,
clearLoading,
}; };
} }

View File

@ -44,6 +44,7 @@ export interface AuthState {
refreshSession: () => Promise<void>; refreshSession: () => Promise<void>;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
clearError: () => void; clearError: () => void;
clearLoading: () => void;
hydrateUserProfile: (profile: Partial<AuthenticatedUser>) => void; hydrateUserProfile: (profile: Partial<AuthenticatedUser>) => void;
} }
@ -53,7 +54,7 @@ type AuthResponseData = {
}; };
export const useAuthStore = create<AuthState>()((set, get) => { export const useAuthStore = create<AuthState>()((set, get) => {
const applyAuthResponse = (data: AuthResponseData) => { const applyAuthResponse = (data: AuthResponseData, keepLoading = false) => {
set({ set({
user: data.user, user: data.user,
session: { session: {
@ -61,7 +62,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
refreshExpiresAt: data.tokens.refreshExpiresAt, refreshExpiresAt: data.tokens.refreshExpiresAt,
}, },
isAuthenticated: true, isAuthenticated: true,
loading: false, loading: keepLoading,
error: null, error: null,
}); });
}; };
@ -82,7 +83,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
if (!parsed.success) { if (!parsed.success) {
throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed"); throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed");
} }
applyAuthResponse(parsed.data); applyAuthResponse(parsed.data, true); // Keep loading for redirect
} catch (error) { } catch (error) {
const errorInfo = getErrorInfo(error); const errorInfo = getErrorInfo(error);
set({ loading: false, error: errorInfo.message, isAuthenticated: false }); set({ loading: false, error: errorInfo.message, isAuthenticated: false });
@ -302,6 +303,8 @@ export const useAuthStore = create<AuthState>()((set, get) => {
clearError: () => set({ error: null }), clearError: () => set({ error: null }),
clearLoading: () => set({ loading: false }),
hydrateUserProfile: profile => { hydrateUserProfile: profile => {
set(state => { set(state => {
if (!state.user) { if (!state.user) {

View File

@ -2,12 +2,25 @@
import { AuthLayout } from "../components"; import { AuthLayout } from "../components";
import { LoginForm } from "@/features/auth/components"; import { LoginForm } from "@/features/auth/components";
import { useAuthStore } from "../services/auth.store";
import { LoadingOverlay } from "@/components/atoms";
export function LoginView() { export function LoginView() {
const { loading, isAuthenticated } = useAuthStore();
return ( return (
<AuthLayout title="Welcome back" subtitle="Sign in to your Assist Solutions account"> <>
<LoginForm /> <AuthLayout title="Welcome back" subtitle="Sign in to your Assist Solutions account">
</AuthLayout> <LoginForm />
</AuthLayout>
{/* Full-page loading overlay during authentication */}
<LoadingOverlay
isVisible={loading && isAuthenticated}
title="Redirecting to dashboard..."
subtitle="Please wait while we load your account"
/>
</>
); );
} }

View File

@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { AuthLayout } from "../components"; import { AuthLayout } from "../components";
import { SetPasswordForm } from "@/features/auth/components"; import { SetPasswordForm } from "@/features/auth/components";
import { LoadingOverlay } from "@/components/atoms";
function SetPasswordContent() { function SetPasswordContent() {
const router = useRouter(); const router = useRouter();
@ -87,9 +88,12 @@ export function SetPasswordView() {
<Suspense <Suspense
fallback={ fallback={
<AuthLayout title="Set password" subtitle="Preparing your account transfer..."> <AuthLayout title="Set password" subtitle="Preparing your account transfer...">
<div className="flex items-center justify-center py-8"> <LoadingOverlay
<div className="h-10 w-10 border-b-2 border-blue-600 rounded-full animate-spin" /> isVisible={true}
</div> title="Preparing your account..."
subtitle="Please wait while we set up your transfer"
overlayClassName="bg-transparent"
/>
</AuthLayout> </AuthLayout>
} }
> >

View File

@ -2,25 +2,38 @@
import { AuthLayout } from "../components"; import { AuthLayout } from "../components";
import { SignupForm } from "@/features/auth/components"; import { SignupForm } from "@/features/auth/components";
import { useAuthStore } from "../services/auth.store";
import { LoadingOverlay } from "@/components/atoms";
export function SignupView() { export function SignupView() {
const { loading, isAuthenticated } = useAuthStore();
return ( return (
<AuthLayout <>
title="Create your portal account" <AuthLayout
subtitle="Verify your details and set up secure access in a few guided steps" title="Create your portal account"
> subtitle="Verify your details and set up secure access in a few guided steps"
<div className="space-y-8"> >
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5"> <div className="space-y-8">
<h2 className="text-sm font-semibold text-blue-800 mb-3">What you&apos;ll need</h2> <div className="bg-blue-50 border border-blue-200 rounded-xl p-5">
<ul className="text-sm text-blue-700 space-y-2 list-disc list-inside"> <h2 className="text-sm font-semibold text-blue-800 mb-3">What you&apos;ll need</h2>
<li>Your Assist Solutions customer number</li> <ul className="text-sm text-blue-700 space-y-2 list-disc list-inside">
<li>Primary contact details and service address</li> <li>Your Assist Solutions customer number</li>
<li>A secure password that meets our enhanced requirements</li> <li>Primary contact details and service address</li>
</ul> <li>A secure password that meets our enhanced requirements</li>
</ul>
</div>
<SignupForm />
</div> </div>
<SignupForm /> </AuthLayout>
</div>
</AuthLayout> {/* Full-page loading overlay during authentication */}
<LoadingOverlay
isVisible={loading && isAuthenticated}
title="Setting up your account..."
subtitle="Please wait while we prepare your dashboard"
/>
</>
); );
} }

View File

@ -36,7 +36,7 @@ export function useDashboardSummary() {
} }
try { try {
const response = await apiClient.GET<DashboardSummary>("/api/users/summary"); const response = await apiClient.GET<DashboardSummary>("/api/me/summary");
return getDataOrThrow<DashboardSummary>(response, "Dashboard summary response was empty"); return getDataOrThrow<DashboardSummary>(response, "Dashboard summary response was empty");
} catch (error) { } catch (error) {
// Transform API errors to DashboardError format // Transform API errors to DashboardError format

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { Activity, DashboardSummary } from "@customer-portal/domain"; import type { Activity, DashboardSummary } from "@customer-portal/domain";
@ -31,7 +31,12 @@ import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
export function DashboardView() { export function DashboardView() {
const router = useRouter(); 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 { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
const upcomingInvoice = summary?.nextInvoice ?? null; const upcomingInvoice = summary?.nextInvoice ?? null;
const createSsoLinkMutation = useCreateInvoiceSsoLink(); const createSsoLinkMutation = useCreateInvoiceSsoLink();

View File

@ -58,7 +58,7 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
queryKey: queryKeys.subscriptions.list(status ? { status } : undefined), queryKey: queryKeys.subscriptions.list(status ? { status } : undefined),
queryFn: async () => { queryFn: async () => {
const response = await apiClient.GET<SubscriptionList>( const response = await apiClient.GET<SubscriptionList>(
"/subscriptions", "/api/subscriptions",
status ? { params: { query: { status } } } : undefined status ? { params: { query: { status } } } : undefined
); );
return toSubscriptionList(getNullableData<SubscriptionList>(response)); return toSubscriptionList(getNullableData<SubscriptionList>(response));
@ -96,7 +96,7 @@ export function useSubscriptionStats() {
return useQuery({ return useQuery({
queryKey: queryKeys.subscriptions.stats(), queryKey: queryKeys.subscriptions.stats(),
queryFn: async () => { queryFn: async () => {
const response = await apiClient.GET<typeof emptyStats>("/subscriptions/stats"); const response = await apiClient.GET<typeof emptyStats>("/api/subscriptions/stats");
return getDataOrDefault<typeof emptyStats>(response, emptyStats); return getDataOrDefault<typeof emptyStats>(response, emptyStats);
}, },
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,