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:
parent
2b54773ebf
commit
6390749150
@ -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
|
||||
|
||||
41
apps/portal/src/components/atoms/LoadingOverlay.tsx
Normal file
41
apps/portal/src/components/atoms/LoadingOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
apps/portal/src/components/atoms/Spinner.tsx
Normal file
42
apps/portal/src/components/atoms/Spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@ -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'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'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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user