**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:
2025-11-02 14:00:05 +01:00
parent 92b7de352c
commit b181182c3b
22 changed files with 390 additions and 127 deletions

View File

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

View File

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

View File

@@ -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`}
> >

View File

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

View File

@@ -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';

View File

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

View File

@@ -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';

View 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>
);
}

View 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';

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

View File

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

View File

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

View File

@@ -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';

View File

@@ -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,14 +69,62 @@ function calculateExpiry(expiresIn?: number): number {
return Date.now() + seconds * 1000; return Date.now() + seconds * 1000;
} }
export const useAuthStore = create<AuthState>((set, get) => ({ /**
// Initial state * Custom storage adapter for Zustand persist
user: null, * Uses our encrypted token storage functions
accessToken: null, */
refreshToken: null, const authStorage = {
isAuthenticated: false, // eslint-disable-next-line @typescript-eslint/no-unused-vars
isLoading: true, // Start as loading to check stored tokens getItem: async (_name: string): Promise<string | null> => {
tokenExpiresAt: 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
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false, // No longer needed - persist handles hydration
tokenExpiresAt: null,
// Set complete auth state (user + tokens) // Set complete auth state (user + tokens)
setAuth: async (user, accessToken, refreshToken, expiresIn) => { setAuth: async (user, accessToken, refreshToken, expiresIn) => {
@@ -158,50 +207,49 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}); });
}, },
// Load auth from storage on app start /**
loadAuthFromStorage: async () => { * @deprecated No longer needed with persist middleware
try { * The persist middleware automatically hydrates tokens on store initialization
const tokens = await getTokens(); * Kept for backward compatibility but does nothing
*/
loadAuthFromStorage: async () => {
// No-op: persist middleware handles this automatically
console.warn('loadAuthFromStorage() is deprecated and no longer necessary');
},
if (tokens?.accessToken && tokens?.refreshToken) { // Check if current token is expired
// Validate token format isTokenExpired: () => {
if (isValidToken(tokens.accessToken) && isValidToken(tokens.refreshToken)) { const { tokenExpiresAt } = get();
set({ if (!tokenExpiresAt) return true;
accessToken: tokens.accessToken, return Date.now() >= tokenExpiresAt;
refreshToken: tokens.refreshToken, },
isAuthenticated: true, }),
isLoading: false, {
// User will be loaded separately via API call name: 'auth_store', // Storage key
}); storage: createJSONStorage(() => authStorage),
return; partialize: (state) => ({
} // Only persist tokens and auth status, not user or computed values
} accessToken: state.accessToken,
} catch (error) { refreshToken: state.refreshToken,
console.error('Failed to load auth from storage:', error); isAuthenticated: state.isAuthenticated,
}),
onRehydrateStorage: () => {
return (state, error) => {
if (error) {
console.error('Failed to rehydrate auth store:', error);
}
};
},
} }
)
// No valid tokens found );
set({ isLoading: false });
},
// Check if current token is expired
isTokenExpired: () => {
const { tokenExpiresAt } = get();
if (!tokenExpiresAt) return true;
return Date.now() >= tokenExpiresAt;
},
}));
/** /**
* 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);
}
} }

View File

@@ -29,7 +29,7 @@ let mockAuthState: {
user: null, user: null,
}; };
jest.mock('@/stores/authStore', () => ({ jest.mock('@/lib/stores/authStore', () => ({
useAuthStore: () => mockAuthState, useAuthStore: () => mockAuthState,
})); }));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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