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

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 { 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<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
aria-busy={loading || undefined}
{...anchorProps}
>
<span className="inline-flex items-center gap-2">
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
) : (
leftIcon
)}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span>
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span>
</a>
);
}
@ -104,12 +100,8 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
aria-busy={loading || undefined}
{...buttonProps}
>
<span className="inline-flex items-center gap-2">
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
) : (
leftIcon
)}
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span>

View File

@ -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,

View File

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

View File

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

View File

@ -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,
};
}

View File

@ -44,6 +44,7 @@ export interface AuthState {
refreshSession: () => Promise<void>;
checkAuth: () => Promise<void>;
clearError: () => void;
clearLoading: () => void;
hydrateUserProfile: (profile: Partial<AuthenticatedUser>) => void;
}
@ -53,7 +54,7 @@ type AuthResponseData = {
};
export const useAuthStore = create<AuthState>()((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<AuthState>()((set, get) => {
refreshExpiresAt: data.tokens.refreshExpiresAt,
},
isAuthenticated: true,
loading: false,
loading: keepLoading,
error: null,
});
};
@ -82,7 +83,7 @@ export const useAuthStore = create<AuthState>()((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<AuthState>()((set, get) => {
clearError: () => set({ error: null }),
clearLoading: () => set({ loading: false }),
hydrateUserProfile: profile => {
set(state => {
if (!state.user) {

View File

@ -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 (
<AuthLayout title="Welcome back" subtitle="Sign in to your Assist Solutions account">
<LoginForm />
</AuthLayout>
<>
<AuthLayout title="Welcome back" subtitle="Sign in to your Assist Solutions account">
<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 { 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() {
<Suspense
fallback={
<AuthLayout title="Set password" subtitle="Preparing your account transfer...">
<div className="flex items-center justify-center py-8">
<div className="h-10 w-10 border-b-2 border-blue-600 rounded-full animate-spin" />
</div>
<LoadingOverlay
isVisible={true}
title="Preparing your account..."
subtitle="Please wait while we set up your transfer"
overlayClassName="bg-transparent"
/>
</AuthLayout>
}
>

View File

@ -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 (
<AuthLayout
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">
<h2 className="text-sm font-semibold text-blue-800 mb-3">What you&apos;ll need</h2>
<ul className="text-sm text-blue-700 space-y-2 list-disc list-inside">
<li>Your Assist Solutions customer number</li>
<li>Primary contact details and service address</li>
<li>A secure password that meets our enhanced requirements</li>
</ul>
<>
<AuthLayout
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">
<h2 className="text-sm font-semibold text-blue-800 mb-3">What you&apos;ll need</h2>
<ul className="text-sm text-blue-700 space-y-2 list-disc list-inside">
<li>Your Assist Solutions customer number</li>
<li>Primary contact details and service address</li>
<li>A secure password that meets our enhanced requirements</li>
</ul>
</div>
<SignupForm />
</div>
<SignupForm />
</div>
</AuthLayout>
</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 {
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");
} catch (error) {
// Transform API errors to DashboardError format

View File

@ -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();

View File

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