**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 { useEffect, useRef } from 'react';
import { PasswordResetConfirmForm } from '@/components/auth/PasswordResetConfirmForm';
import dynamic from 'next/dynamic';
import { Alert } from '@/components/ui/alert';
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() {
const searchParams = useSearchParams();
const router = useRouter();

View File

@@ -1,6 +1,20 @@
'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() {
return (

View File

@@ -24,7 +24,33 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
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
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>

View File

@@ -3,7 +3,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
import { AuthInitializer } from '@/components/auth';
import { ThemeProvider } from '@/components/theme';
export function Providers({ children }: { children: React.ReactNode }) {
@@ -14,7 +13,8 @@ export function Providers({ children }: { children: React.ReactNode }) {
queries: {
staleTime: 60 * 1000, // 1 minute
retry: 1,
refetchOnWindowFocus: true,
refetchOnWindowFocus: false, // Disabled - use selective refetching per query
refetchOnReconnect: true, // Keep for session data
},
mutations: {
retry: false,
@@ -26,7 +26,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<AuthInitializer />
{/* AuthInitializer removed - Zustand persist middleware handles auto-hydration */}
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

View File

@@ -8,7 +8,7 @@
import { useEffect } from 'react';
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 config from '@/config/app.config';

View File

@@ -7,7 +7,7 @@
'use client';
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/authStore';
import { useAuthStore } from '@/lib/stores/authStore';
/**
* AuthInitializer - Initializes auth state from encrypted storage on mount

View File

@@ -1,8 +1,5 @@
// Authentication components
// Initialization
export { AuthInitializer } from './AuthInitializer';
// Route protection
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 { usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore';
import { useAuthStore } from '@/lib/stores/authStore';
import { useLogout } from '@/lib/api/hooks/useAuth';
import {
DropdownMenu,

View File

@@ -28,7 +28,7 @@ let refreshPromise: Promise<string> | null = null;
* Dynamically imported to avoid circular dependencies
*/
const getAuthStore = async () => {
const { useAuthStore } = await import('@/stores/authStore');
const { useAuthStore } = await import('@/lib/stores/authStore');
return useAuthStore.getState();
};

View File

@@ -20,8 +20,8 @@ import {
confirmPasswordReset,
changeCurrentUserPassword,
} from '../client';
import { useAuthStore } from '@/stores/authStore';
import type { User } from '@/stores/authStore';
import { useAuthStore } from '@/lib/stores/authStore';
import type { User } from '@/lib/stores/authStore';
import { parseAPIError, getGeneralError } from '../errors';
import { isTokenWithUser } from '../types';
import config from '@/config/app.config';

View File

@@ -1,9 +1,10 @@
/**
* 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 { persist, createJSONStorage } from 'zustand/middleware';
import { saveTokens, getTokens, clearTokens } from '@/lib/auth/storage';
/**
@@ -68,14 +69,62 @@ function calculateExpiry(expiresIn?: number): number {
return Date.now() + seconds * 1000;
}
export const useAuthStore = create<AuthState>((set, get) => ({
// Initial state
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: true, // Start as loading to check stored tokens
tokenExpiresAt: null,
/**
* 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
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false, // No longer needed - persist handles hydration
tokenExpiresAt: null,
// Set complete auth state (user + tokens)
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 () => {
try {
const tokens = await getTokens();
/**
* @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 () => {
// No-op: persist middleware handles this automatically
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);
// Check if current token is expired
isTokenExpired: () => {
const { tokenExpiresAt } = get();
if (!tokenExpiresAt) return true;
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);
}
};
},
}
// 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
* Call this on app startup
* Errors are logged but don't throw to prevent app crashes
* @deprecated No longer needed with persist middleware
* The persist middleware automatically hydrates the store on initialization
* Kept for backward compatibility but does nothing
*/
export async function initializeAuth(): Promise<void> {
try {
await useAuthStore.getState().loadAuthFromStorage();
} catch (error) {
// Log error but don't throw - app should continue even if auth init fails
console.error('Failed to initialize auth:', error);
}
// No-op: persist middleware handles initialization automatically
console.warn('initializeAuth() is deprecated and no longer necessary');
}

View File

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

View File

@@ -5,10 +5,10 @@
import { render, waitFor } from '@testing-library/react';
import { AuthInitializer } from '@/components/auth/AuthInitializer';
import { useAuthStore } from '@/stores/authStore';
import { useAuthStore } from '@/lib/stores/authStore';
// Mock the auth store
jest.mock('@/stores/authStore', () => ({
jest.mock('@/lib/stores/authStore', () => ({
useAuthStore: jest.fn(),
}));

View File

@@ -40,7 +40,7 @@ jest.mock('next/navigation', () => ({
}));
// Mock auth store
jest.mock('@/stores/authStore', () => ({
jest.mock('@/lib/stores/authStore', () => ({
useAuthStore: () => ({
isAuthenticated: false,
setAuth: jest.fn(),

View File

@@ -38,7 +38,7 @@ jest.mock('next/navigation', () => ({
}),
}));
jest.mock('@/stores/authStore', () => ({
jest.mock('@/lib/stores/authStore', () => ({
useAuthStore: () => ({
isAuthenticated: false,
setAuth: jest.fn(),

View File

@@ -6,13 +6,13 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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 { usePathname } from 'next/navigation';
import type { User } from '@/stores/authStore';
import type { User } from '@/lib/stores/authStore';
// Mock dependencies
jest.mock('@/stores/authStore', () => ({
jest.mock('@/lib/stores/authStore', () => ({
useAuthStore: jest.fn(),
}));

View File

@@ -25,7 +25,7 @@ let mockAuthState: {
refreshToken: null,
};
jest.mock('@/stores/authStore', () => ({
jest.mock('@/lib/stores/authStore', () => ({
useAuthStore: (selector?: (state: any) => any) => {
if (selector) {
return selector(mockAuthState);

View File

@@ -2,7 +2,7 @@
* 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';
// Mock storage module
@@ -386,73 +386,41 @@ describe('Auth Store', () => {
});
});
describe('loadAuthFromStorage', () => {
it('should load valid tokens from storage', async () => {
(storage.getTokens as jest.Mock).mockResolvedValue({
accessToken: 'valid.access.token',
refreshToken: 'valid.refresh.token',
});
describe('loadAuthFromStorage (deprecated)', () => {
it('should log deprecation warning', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
await useAuthStore.getState().loadAuthFromStorage();
const state = useAuthStore.getState();
expect(state.accessToken).toBe('valid.access.token');
expect(state.refreshToken).toBe('valid.refresh.token');
expect(state.isAuthenticated).toBe(true);
expect(state.isLoading).toBe(false);
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'loadAuthFromStorage() is deprecated and no longer necessary'
);
it('should handle null tokens from storage', async () => {
(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);
consoleWarnSpy.mockRestore();
});
});
describe('initializeAuth', () => {
it('should call loadAuthFromStorage', async () => {
(storage.getTokens as jest.Mock).mockResolvedValue({
accessToken: 'valid.access.token',
refreshToken: 'valid.refresh.token',
});
describe('initializeAuth (deprecated)', () => {
it('should log deprecation warning', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
const { initializeAuth } = await import('@/stores/authStore');
const { initializeAuth } = await import('@/lib/stores/authStore');
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 () => {
(storage.getTokens as jest.Mock).mockRejectedValue(new Error('Storage error'));
it('should not throw', async () => {
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();
consoleWarnSpy.mockRestore();
});
});
});