forked from cardosofelipe/fast-next-template
**Authentication Refactor:** Remove authStore and its associated tests, transitioning to the new authentication model. Add dynamic loading for PasswordResetConfirmForm to optimize performance. Include a theme initialization script in layout.tsx to prevent FOUC.
This commit is contained in:
@@ -7,10 +7,23 @@
|
|||||||
|
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { PasswordResetConfirmForm } from '@/components/auth/PasswordResetConfirmForm';
|
import dynamic from 'next/dynamic';
|
||||||
import { Alert } from '@/components/ui/alert';
|
import { Alert } from '@/components/ui/alert';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
// Code-split PasswordResetConfirmForm (319 lines)
|
||||||
|
const PasswordResetConfirmForm = dynamic(
|
||||||
|
() => import('@/components/auth/PasswordResetConfirmForm').then((mod) => ({ default: mod.PasswordResetConfirmForm })),
|
||||||
|
{
|
||||||
|
loading: () => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="animate-pulse h-10 bg-muted rounded" />
|
||||||
|
<div className="animate-pulse h-10 bg-muted rounded" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default function PasswordResetConfirmContent() {
|
export default function PasswordResetConfirmContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { RegisterForm } from '@/components/auth/RegisterForm';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
// Code-split RegisterForm (313 lines)
|
||||||
|
const RegisterForm = dynamic(
|
||||||
|
() => import('@/components/auth/RegisterForm').then((mod) => ({ default: mod.RegisterForm })),
|
||||||
|
{
|
||||||
|
loading: () => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="animate-pulse h-10 bg-muted rounded" />
|
||||||
|
<div className="animate-pulse h-10 bg-muted rounded" />
|
||||||
|
<div className="animate-pulse h-10 bg-muted rounded" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,7 +24,33 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
{/* Theme initialization script - runs before React hydrates to prevent FOUC */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
const theme = localStorage.getItem('theme') || 'system';
|
||||||
|
let resolved;
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
} else {
|
||||||
|
resolved = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(resolved);
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail - theme will be set by ThemeProvider
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AuthInitializer } from '@/components/auth';
|
|
||||||
import { ThemeProvider } from '@/components/theme';
|
import { ThemeProvider } from '@/components/theme';
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
@@ -14,7 +13,8 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
queries: {
|
queries: {
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
retry: 1,
|
retry: 1,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: false, // Disabled - use selective refetching per query
|
||||||
|
refetchOnReconnect: true, // Keep for session data
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -26,7 +26,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthInitializer />
|
{/* AuthInitializer removed - Zustand persist middleware handles auto-hydration */}
|
||||||
{children}
|
{children}
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/lib/stores/authStore';
|
||||||
import { useMe } from '@/lib/api/hooks/useAuth';
|
import { useMe } from '@/lib/api/hooks/useAuth';
|
||||||
import config from '@/config/app.config';
|
import config from '@/config/app.config';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/lib/stores/authStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AuthInitializer - Initializes auth state from encrypted storage on mount
|
* AuthInitializer - Initializes auth state from encrypted storage on mount
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
// Authentication components
|
// Authentication components
|
||||||
|
|
||||||
// Initialization
|
|
||||||
export { AuthInitializer } from './AuthInitializer';
|
|
||||||
|
|
||||||
// Route protection
|
// Route protection
|
||||||
export { AuthGuard } from './AuthGuard';
|
export { AuthGuard } from './AuthGuard';
|
||||||
|
|
||||||
|
|||||||
101
frontend/src/components/forms/FormField.tsx
Normal file
101
frontend/src/components/forms/FormField.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* FormField Component
|
||||||
|
* Reusable form field with integrated label, input, and error display
|
||||||
|
* Designed for react-hook-form with proper accessibility attributes
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ComponentProps, ReactNode } from 'react';
|
||||||
|
import { FieldError } from 'react-hook-form';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
export interface FormFieldProps extends Omit<ComponentProps<typeof Input>, 'children'> {
|
||||||
|
/** Field label text */
|
||||||
|
label: string;
|
||||||
|
/** Field name/id - optional if provided via register() */
|
||||||
|
name?: string;
|
||||||
|
/** Is field required? Shows asterisk if true */
|
||||||
|
required?: boolean;
|
||||||
|
/** Form error object from react-hook-form */
|
||||||
|
error?: FieldError;
|
||||||
|
/** Label description or helper text */
|
||||||
|
description?: string;
|
||||||
|
/** Additional content after input (e.g., password requirements) */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FormField - Standardized form field with label and error handling
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Automatic error ID generation for accessibility
|
||||||
|
* - Required indicator
|
||||||
|
* - Error message display
|
||||||
|
* - Helper text/description support
|
||||||
|
* - Full ARIA attribute support
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <FormField
|
||||||
|
* label="Email"
|
||||||
|
* name="email"
|
||||||
|
* type="email"
|
||||||
|
* required
|
||||||
|
* error={form.formState.errors.email}
|
||||||
|
* disabled={isSubmitting}
|
||||||
|
* {...form.register('email')}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function FormField({
|
||||||
|
label,
|
||||||
|
name: explicitName,
|
||||||
|
required = false,
|
||||||
|
error,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
...inputProps
|
||||||
|
}: FormFieldProps) {
|
||||||
|
// Extract name from inputProps (from register()) or use explicit name
|
||||||
|
// register() adds a name property that may not be in the type
|
||||||
|
const registerName = ('name' in inputProps) ? (inputProps as { name: string }).name : undefined;
|
||||||
|
const name = explicitName || registerName;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new Error('FormField: name must be provided either explicitly or via register()');
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorId = error ? `${name}-error` : undefined;
|
||||||
|
const descriptionId = description ? `${name}-description` : undefined;
|
||||||
|
const ariaDescribedBy = [errorId, descriptionId].filter(Boolean).join(' ') || undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{label && (
|
||||||
|
<Label htmlFor={name}>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive"> *</span>}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p id={descriptionId} className="text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
id={name}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} className="text-sm text-destructive" role="alert">
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/components/forms/index.ts
Normal file
5
frontend/src/components/forms/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Shared form components and utilities
|
||||||
|
export { FormField } from './FormField';
|
||||||
|
export type { FormFieldProps } from './FormField';
|
||||||
|
export { useFormError } from './useFormError';
|
||||||
|
export type { UseFormErrorReturn } from './useFormError';
|
||||||
91
frontend/src/components/forms/useFormError.ts
Normal file
91
frontend/src/components/forms/useFormError.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* useFormError Hook
|
||||||
|
* Handles server error state and API error parsing for forms
|
||||||
|
* Standardizes error handling across all form components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { UseFormReturn, FieldValues, Path } from 'react-hook-form';
|
||||||
|
import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/errors';
|
||||||
|
|
||||||
|
export interface UseFormErrorReturn {
|
||||||
|
/** Current server error message */
|
||||||
|
serverError: string | null;
|
||||||
|
/** Set server error manually */
|
||||||
|
setServerError: (error: string | null) => void;
|
||||||
|
/** Handle API error and update form with field-specific errors */
|
||||||
|
handleFormError: (error: unknown) => void;
|
||||||
|
/** Clear all errors */
|
||||||
|
clearErrors: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useFormError - Standardized form error handling
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Server error state management
|
||||||
|
* - API error parsing with type guards
|
||||||
|
* - Automatic field error mapping to react-hook-form
|
||||||
|
* - General error message extraction
|
||||||
|
*
|
||||||
|
* @param form - react-hook-form instance
|
||||||
|
* @returns Error handling utilities
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const form = useForm<LoginFormData>({...});
|
||||||
|
* const { serverError, handleFormError, clearErrors } = useFormError(form);
|
||||||
|
*
|
||||||
|
* const onSubmit = async (data: LoginFormData) => {
|
||||||
|
* try {
|
||||||
|
* clearErrors();
|
||||||
|
* await loginMutation.mutateAsync(data);
|
||||||
|
* } catch (error) {
|
||||||
|
* handleFormError(error);
|
||||||
|
* }
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useFormError<TFieldValues extends FieldValues>(
|
||||||
|
form: UseFormReturn<TFieldValues>
|
||||||
|
): UseFormErrorReturn {
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleFormError = useCallback(
|
||||||
|
(error: unknown) => {
|
||||||
|
// Handle API errors with type guard
|
||||||
|
if (isAPIErrorArray(error)) {
|
||||||
|
// Set general error message
|
||||||
|
const generalError = getGeneralError(error);
|
||||||
|
if (generalError) {
|
||||||
|
setServerError(generalError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set field-specific errors
|
||||||
|
const fieldErrors = getFieldErrors(error);
|
||||||
|
Object.entries(fieldErrors).forEach(([field, message]) => {
|
||||||
|
// Check if field exists in form values to avoid setting invalid fields
|
||||||
|
if (field in form.getValues()) {
|
||||||
|
form.setError(field as Path<TFieldValues>, { message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unexpected error format
|
||||||
|
setServerError('An unexpected error occurred. Please try again.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearErrors = useCallback(() => {
|
||||||
|
setServerError(null);
|
||||||
|
form.clearErrors();
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverError,
|
||||||
|
setServerError,
|
||||||
|
handleFormError,
|
||||||
|
clearErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/lib/stores/authStore';
|
||||||
import { useLogout } from '@/lib/api/hooks/useAuth';
|
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ let refreshPromise: Promise<string> | null = null;
|
|||||||
* Dynamically imported to avoid circular dependencies
|
* Dynamically imported to avoid circular dependencies
|
||||||
*/
|
*/
|
||||||
const getAuthStore = async () => {
|
const getAuthStore = async () => {
|
||||||
const { useAuthStore } = await import('@/stores/authStore');
|
const { useAuthStore } = await import('@/lib/stores/authStore');
|
||||||
return useAuthStore.getState();
|
return useAuthStore.getState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import {
|
|||||||
confirmPasswordReset,
|
confirmPasswordReset,
|
||||||
changeCurrentUserPassword,
|
changeCurrentUserPassword,
|
||||||
} from '../client';
|
} from '../client';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/lib/stores/authStore';
|
||||||
import type { User } from '@/stores/authStore';
|
import type { User } from '@/lib/stores/authStore';
|
||||||
import { parseAPIError, getGeneralError } from '../errors';
|
import { parseAPIError, getGeneralError } from '../errors';
|
||||||
import { isTokenWithUser } from '../types';
|
import { isTokenWithUser } from '../types';
|
||||||
import config from '@/config/app.config';
|
import config from '@/config/app.config';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Authentication Store - Zustand with secure token storage
|
* Authentication Store - Zustand with secure token storage
|
||||||
* Implements proper state management with validation
|
* Implements proper state management with validation and automatic persistence
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { saveTokens, getTokens, clearTokens } from '@/lib/auth/storage';
|
import { saveTokens, getTokens, clearTokens } from '@/lib/auth/storage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,13 +69,61 @@ function calculateExpiry(expiresIn?: number): number {
|
|||||||
return Date.now() + seconds * 1000;
|
return Date.now() + seconds * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
/**
|
||||||
|
* Custom storage adapter for Zustand persist
|
||||||
|
* Uses our encrypted token storage functions
|
||||||
|
*/
|
||||||
|
const authStorage = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
getItem: async (_name: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const tokens = await getTokens();
|
||||||
|
if (!tokens) return null;
|
||||||
|
|
||||||
|
// Return the tokens as a JSON string that persist middleware expects
|
||||||
|
return JSON.stringify({
|
||||||
|
state: {
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
isAuthenticated: !!(tokens.accessToken && tokens.refreshToken),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load auth from storage:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setItem: async (_name: string, value: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
const { accessToken, refreshToken } = parsed.state;
|
||||||
|
|
||||||
|
if (accessToken && refreshToken) {
|
||||||
|
await saveTokens({ accessToken, refreshToken });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save auth to storage:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
removeItem: async (_name: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await clearTokens();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear auth from storage:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
user: null,
|
user: null,
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true, // Start as loading to check stored tokens
|
isLoading: false, // No longer needed - persist handles hydration
|
||||||
tokenExpiresAt: null,
|
tokenExpiresAt: null,
|
||||||
|
|
||||||
// Set complete auth state (user + tokens)
|
// Set complete auth state (user + tokens)
|
||||||
@@ -158,30 +207,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Load auth from storage on app start
|
/**
|
||||||
|
* @deprecated No longer needed with persist middleware
|
||||||
|
* The persist middleware automatically hydrates tokens on store initialization
|
||||||
|
* Kept for backward compatibility but does nothing
|
||||||
|
*/
|
||||||
loadAuthFromStorage: async () => {
|
loadAuthFromStorage: async () => {
|
||||||
try {
|
// No-op: persist middleware handles this automatically
|
||||||
const tokens = await getTokens();
|
console.warn('loadAuthFromStorage() is deprecated and no longer necessary');
|
||||||
|
|
||||||
if (tokens?.accessToken && tokens?.refreshToken) {
|
|
||||||
// Validate token format
|
|
||||||
if (isValidToken(tokens.accessToken) && isValidToken(tokens.refreshToken)) {
|
|
||||||
set({
|
|
||||||
accessToken: tokens.accessToken,
|
|
||||||
refreshToken: tokens.refreshToken,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
// User will be loaded separately via API call
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load auth from storage:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No valid tokens found
|
|
||||||
set({ isLoading: false });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Check if current token is expired
|
// Check if current token is expired
|
||||||
@@ -190,18 +223,33 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
if (!tokenExpiresAt) return true;
|
if (!tokenExpiresAt) return true;
|
||||||
return Date.now() >= tokenExpiresAt;
|
return Date.now() >= tokenExpiresAt;
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth_store', // Storage key
|
||||||
|
storage: createJSONStorage(() => authStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
// Only persist tokens and auth status, not user or computed values
|
||||||
|
accessToken: state.accessToken,
|
||||||
|
refreshToken: state.refreshToken,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => {
|
||||||
|
return (state, error) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to rehydrate auth store:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize auth store from storage
|
* @deprecated No longer needed with persist middleware
|
||||||
* Call this on app startup
|
* The persist middleware automatically hydrates the store on initialization
|
||||||
* Errors are logged but don't throw to prevent app crashes
|
* Kept for backward compatibility but does nothing
|
||||||
*/
|
*/
|
||||||
export async function initializeAuth(): Promise<void> {
|
export async function initializeAuth(): Promise<void> {
|
||||||
try {
|
// No-op: persist middleware handles initialization automatically
|
||||||
await useAuthStore.getState().loadAuthFromStorage();
|
console.warn('initializeAuth() is deprecated and no longer necessary');
|
||||||
} catch (error) {
|
|
||||||
// Log error but don't throw - app should continue even if auth init fails
|
|
||||||
console.error('Failed to initialize auth:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ let mockAuthState: {
|
|||||||
user: null,
|
user: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('@/stores/authStore', () => ({
|
jest.mock('@/lib/stores/authStore', () => ({
|
||||||
useAuthStore: () => mockAuthState,
|
useAuthStore: () => mockAuthState,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
|
|
||||||
import { render, waitFor } from '@testing-library/react';
|
import { render, waitFor } from '@testing-library/react';
|
||||||
import { AuthInitializer } from '@/components/auth/AuthInitializer';
|
import { AuthInitializer } from '@/components/auth/AuthInitializer';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/lib/stores/authStore';
|
||||||
|
|
||||||
// Mock the auth store
|
// Mock the auth store
|
||||||
jest.mock('@/stores/authStore', () => ({
|
jest.mock('@/lib/stores/authStore', () => ({
|
||||||
useAuthStore: jest.fn(),
|
useAuthStore: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ jest.mock('next/navigation', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock auth store
|
// Mock auth store
|
||||||
jest.mock('@/stores/authStore', () => ({
|
jest.mock('@/lib/stores/authStore', () => ({
|
||||||
useAuthStore: () => ({
|
useAuthStore: () => ({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
setAuth: jest.fn(),
|
setAuth: jest.fn(),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ jest.mock('next/navigation', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@/stores/authStore', () => ({
|
jest.mock('@/lib/stores/authStore', () => ({
|
||||||
useAuthStore: () => ({
|
useAuthStore: () => ({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
setAuth: jest.fn(),
|
setAuth: jest.fn(),
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/lib/stores/authStore';
|
||||||
import { useLogout } from '@/lib/api/hooks/useAuth';
|
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import type { User } from '@/stores/authStore';
|
import type { User } from '@/lib/stores/authStore';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
jest.mock('@/stores/authStore', () => ({
|
jest.mock('@/lib/stores/authStore', () => ({
|
||||||
useAuthStore: jest.fn(),
|
useAuthStore: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ let mockAuthState: {
|
|||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('@/stores/authStore', () => ({
|
jest.mock('@/lib/stores/authStore', () => ({
|
||||||
useAuthStore: (selector?: (state: any) => any) => {
|
useAuthStore: (selector?: (state: any) => any) => {
|
||||||
if (selector) {
|
if (selector) {
|
||||||
return selector(mockAuthState);
|
return selector(mockAuthState);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Tests for auth store
|
* Tests for auth store
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useAuthStore, type User } from '@/stores/authStore';
|
import { useAuthStore, type User } from '@/lib/stores/authStore';
|
||||||
import * as storage from '@/lib/auth/storage';
|
import * as storage from '@/lib/auth/storage';
|
||||||
|
|
||||||
// Mock storage module
|
// Mock storage module
|
||||||
@@ -386,73 +386,41 @@ describe('Auth Store', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadAuthFromStorage', () => {
|
describe('loadAuthFromStorage (deprecated)', () => {
|
||||||
it('should load valid tokens from storage', async () => {
|
it('should log deprecation warning', async () => {
|
||||||
(storage.getTokens as jest.Mock).mockResolvedValue({
|
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||||
accessToken: 'valid.access.token',
|
|
||||||
refreshToken: 'valid.refresh.token',
|
|
||||||
});
|
|
||||||
|
|
||||||
await useAuthStore.getState().loadAuthFromStorage();
|
await useAuthStore.getState().loadAuthFromStorage();
|
||||||
|
|
||||||
const state = useAuthStore.getState();
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||||
expect(state.accessToken).toBe('valid.access.token');
|
'loadAuthFromStorage() is deprecated and no longer necessary'
|
||||||
expect(state.refreshToken).toBe('valid.refresh.token');
|
);
|
||||||
expect(state.isAuthenticated).toBe(true);
|
|
||||||
expect(state.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null tokens from storage', async () => {
|
consoleWarnSpy.mockRestore();
|
||||||
(storage.getTokens as jest.Mock).mockResolvedValue(null);
|
|
||||||
|
|
||||||
await useAuthStore.getState().loadAuthFromStorage();
|
|
||||||
|
|
||||||
const state = useAuthStore.getState();
|
|
||||||
expect(state.isAuthenticated).toBe(false);
|
|
||||||
expect(state.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject invalid token format from storage', async () => {
|
|
||||||
(storage.getTokens as jest.Mock).mockResolvedValue({
|
|
||||||
accessToken: 'invalid',
|
|
||||||
refreshToken: 'valid.refresh.token',
|
|
||||||
});
|
|
||||||
|
|
||||||
await useAuthStore.getState().loadAuthFromStorage();
|
|
||||||
|
|
||||||
const state = useAuthStore.getState();
|
|
||||||
expect(state.isAuthenticated).toBe(false);
|
|
||||||
expect(state.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle storage errors gracefully', async () => {
|
|
||||||
(storage.getTokens as jest.Mock).mockRejectedValue(new Error('Storage error'));
|
|
||||||
|
|
||||||
await useAuthStore.getState().loadAuthFromStorage();
|
|
||||||
|
|
||||||
const state = useAuthStore.getState();
|
|
||||||
expect(state.isLoading).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initializeAuth', () => {
|
describe('initializeAuth (deprecated)', () => {
|
||||||
it('should call loadAuthFromStorage', async () => {
|
it('should log deprecation warning', async () => {
|
||||||
(storage.getTokens as jest.Mock).mockResolvedValue({
|
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||||
accessToken: 'valid.access.token',
|
|
||||||
refreshToken: 'valid.refresh.token',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { initializeAuth } = await import('@/stores/authStore');
|
const { initializeAuth } = await import('@/lib/stores/authStore');
|
||||||
await initializeAuth();
|
await initializeAuth();
|
||||||
|
|
||||||
expect(storage.getTokens).toHaveBeenCalled();
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||||
|
'initializeAuth() is deprecated and no longer necessary'
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not throw even if loadAuthFromStorage fails', async () => {
|
it('should not throw', async () => {
|
||||||
(storage.getTokens as jest.Mock).mockRejectedValue(new Error('Storage error'));
|
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||||
|
|
||||||
const { initializeAuth } = await import('@/stores/authStore');
|
const { initializeAuth } = await import('@/lib/stores/authStore');
|
||||||
await expect(initializeAuth()).resolves.not.toThrow();
|
await expect(initializeAuth()).resolves.not.toThrow();
|
||||||
|
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user