From 23f7cbb9de8314657f1b74c397ee538e9c1d3e7b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:01:17 +0000 Subject: [PATCH] Refactor: Implement full authentication overhaul --- src/components/auth/TOTPSetup.tsx | 26 +-- src/hooks/useAuth.tsx | 89 ++++------ src/hooks/useRequireMFA.ts | 36 +++- src/lib/authService.ts | 267 ++++++++++++++++++++++++++++++ src/lib/sessionFlags.ts | 83 ++++++++++ src/pages/AuthCallback.tsx | 39 ++--- src/pages/MFAStepUp.tsx | 33 ++-- src/types/auth.ts | 74 +++++++++ 8 files changed, 525 insertions(+), 122 deletions(-) create mode 100644 src/lib/authService.ts create mode 100644 src/lib/sessionFlags.ts create mode 100644 src/types/auth.ts diff --git a/src/components/auth/TOTPSetup.tsx b/src/components/auth/TOTPSetup.tsx index b7040e88..b3d49409 100644 --- a/src/components/auth/TOTPSetup.tsx +++ b/src/components/auth/TOTPSetup.tsx @@ -10,19 +10,15 @@ import { useAuth } from '@/hooks/useAuth'; import { supabase } from '@/integrations/supabase/client'; import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2 } from 'lucide-react'; import { MFARemovalDialog } from './MFARemovalDialog'; - -interface TOTPFactor { - id: string; - friendly_name?: string; - factor_type: string; - status: string; - created_at: string; -} +import { setStepUpRequired, getAuthMethod } from '@/lib/sessionFlags'; +import { useNavigate } from 'react-router-dom'; +import type { MFAFactor } from '@/types/auth'; export function TOTPSetup() { const { user } = useAuth(); const { toast } = useToast(); - const [factors, setFactors] = useState([]); + const navigate = useNavigate(); + const [factors, setFactors] = useState([]); const [loading, setLoading] = useState(false); const [enrolling, setEnrolling] = useState(false); const [qrCode, setQrCode] = useState(''); @@ -111,10 +107,14 @@ export function TOTPSetup() { if (verifyError) throw verifyError; - // Check if user signed in via OAuth - const { data: { session } } = await supabase.auth.getSession(); - const provider = session?.user?.app_metadata?.provider; - const isOAuthUser = provider === 'google' || provider === 'discord'; + // Check if user signed in via OAuth and trigger step-up flow + const authMethod = getAuthMethod(); + if (authMethod === 'oauth') { + console.log('[TOTPSetup] OAuth user enrolled MFA, triggering step-up...'); + setStepUpRequired(true, window.location.pathname); + navigate('/auth/mfa-step-up'); + return; + } toast({ title: 'TOTP Enabled', diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 26e1ec59..a5cad96b 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -4,17 +4,14 @@ import { supabase } from '@/integrations/supabase/client'; import type { Profile } from '@/types/database'; import { toast } from '@/hooks/use-toast'; import { authLog, authWarn, authError } from '@/lib/authLogger'; - -export interface CheckAalResult { - needsStepUp: boolean; - hasMfaEnrolled: boolean; - currentLevel: 'aal1' | 'aal2' | null; -} +import type { AALLevel, CheckAalResult } from '@/types/auth'; +import { getSessionAal, checkAalStepUp as checkAalStepUpService, signOutUser } from '@/lib/authService'; +import { clearAllAuthFlags } from '@/lib/sessionFlags'; interface AuthContextType { user: User | null; session: Session | null; - aal: 'aal1' | 'aal2' | null; + aal: AALLevel | null; loading: boolean; pendingEmail: string | null; sessionError: string | null; @@ -29,7 +26,7 @@ const AuthContext = createContext(undefined); function AuthProviderComponent({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [session, setSession] = useState(null); - const [aal, setAal] = useState<'aal1' | 'aal2' | null>(null); + const [aal, setAal] = useState(null); const [loading, setLoading] = useState(true); const [pendingEmail, setPendingEmail] = useState(null); const [sessionError, setSessionError] = useState(null); @@ -88,30 +85,13 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { // Clear any error setSessionError(null); - // Update session and user state based on event - if (event === 'SIGNED_IN' && session) { - authLog('[Auth] SIGNED_IN - user authenticated'); - setSession(session); - setUser(session.user); - const userAal = (session.user as any).aal as 'aal1' | 'aal2' | undefined; - setAal(userAal || 'aal1'); + // Synchronous state updates only + setSession(session); + setUser(session?.user ?? null); + + // Handle loading state + if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') { setLoading(false); - } else if (event === 'INITIAL_SESSION') { - if (session?.user) { - authLog('[Auth] INITIAL_SESSION - user exists'); - setSession(session); - setUser(session.user); - const userAal = (session.user as any).aal as 'aal1' | 'aal2' | undefined; - setAal(userAal || 'aal1'); - setLoading(false); - } else { - authLog('[Auth] INITIAL_SESSION - no user'); - setSession(null); - setUser(null); - setAal(null); - setLoading(false); - return; - } } else if (event === 'SIGNED_OUT') { authLog('[Auth] SIGNED_OUT - clearing state'); setSession(null); @@ -119,13 +99,20 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { setAal(null); setLoading(false); return; - } else { - setSession(session); - setUser(session?.user ?? null); - const userAal = session?.user ? ((session.user as any).aal as 'aal1' | 'aal2' | undefined) : null; - setAal(userAal || null); } + // Defer async operations to avoid blocking the auth state change callback + setTimeout(async () => { + // Get AAL level from Supabase API (ground truth, not cached session data) + if (session) { + const currentAal = await getSessionAal(session); + setAal(currentAal); + authLog('[Auth] Current AAL:', currentAal); + } else { + setAal(null); + } + }, 0); + // Detect confirmed email change: email changed AND no longer pending if ( session?.user && @@ -217,10 +204,11 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { }, []); const signOut = async () => { - const { error } = await supabase.auth.signOut(); - if (error) { - authError('Error signing out:', error); - throw error; + authLog('[Auth] Signing out...'); + const result = await signOutUser(); + if (!result.success) { + authError('Error signing out:', result.error); + throw new Error(result.error); } }; @@ -229,26 +217,7 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { }; const checkAalStepUp = async (): Promise => { - if (!session?.user) { - return { needsStepUp: false, hasMfaEnrolled: false, currentLevel: null }; - } - - try { - const { data: { currentLevel } } = - await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); - - const { data: factors } = await supabase.auth.mfa.listFactors(); - const hasMfaEnrolled = factors?.totp?.some(f => f.status === 'verified') || false; - - return { - needsStepUp: hasMfaEnrolled && currentLevel === 'aal1', - hasMfaEnrolled, - currentLevel: currentLevel as 'aal1' | 'aal2' | null, - }; - } catch (error) { - authError('[Auth] Failed to check AAL status:', error); - return { needsStepUp: false, hasMfaEnrolled: false, currentLevel: null }; - } + return checkAalStepUpService(session); }; const value = { diff --git a/src/hooks/useRequireMFA.ts b/src/hooks/useRequireMFA.ts index db592b35..07886d75 100644 --- a/src/hooks/useRequireMFA.ts +++ b/src/hooks/useRequireMFA.ts @@ -1,21 +1,45 @@ import { useAuth } from './useAuth'; import { useUserRole } from './useUserRole'; +import { useEffect, useState } from 'react'; +import { getEnrolledFactors } from '@/lib/authService'; export function useRequireMFA() { - const { aal } = useAuth(); - const { isModerator, isAdmin, loading } = useUserRole(); + const { aal, session } = useAuth(); + const { isModerator, isAdmin, loading: roleLoading } = useUserRole(); + const [isEnrolled, setIsEnrolled] = useState(false); + const [loading, setLoading] = useState(true); + + // Check actual enrollment status + useEffect(() => { + const checkEnrollment = async () => { + if (!session) { + setIsEnrolled(false); + setLoading(false); + return; + } + + const factors = await getEnrolledFactors(); + setIsEnrolled(factors.length > 0); + setLoading(false); + }; + + if (!roleLoading) { + checkEnrollment(); + } + }, [session, roleLoading]); // MFA is required for moderators and admins const requiresMFA = isModerator() || isAdmin(); - // User has MFA if they have AAL2 - const hasMFA = aal === 'aal2'; + // User has MFA if they have AAL2 AND have enrolled factors + const hasMFA = aal === 'aal2' && isEnrolled; return { requiresMFA, hasMFA, - needsEnrollment: requiresMFA && !hasMFA, + isEnrolled, + needsEnrollment: requiresMFA && !isEnrolled, aal, - loading, + loading: loading || roleLoading, }; } diff --git a/src/lib/authService.ts b/src/lib/authService.ts new file mode 100644 index 00000000..673a67e6 --- /dev/null +++ b/src/lib/authService.ts @@ -0,0 +1,267 @@ +/** + * Centralized Authentication Service + * Handles all authentication flows with consistent AAL checking and MFA verification + */ + +import { supabase } from '@/integrations/supabase/client'; +import type { Session } from '@supabase/supabase-js'; +import type { + AALLevel, + MFAFactor, + CheckAalResult, + AuthServiceResponse, + MFAChallengeResult +} from '@/types/auth'; +import { setStepUpRequired, setAuthMethod, clearAllAuthFlags } from './sessionFlags'; + +/** + * Extract AAL level from session using Supabase API + * Always returns ground truth from server, not cached session data + */ +export async function getSessionAal(session: Session | null): Promise { + if (!session) return 'aal1'; + + try { + const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + + if (error) { + console.error('[AuthService] Error getting AAL:', error); + return 'aal1'; + } + + return (data.currentLevel as AALLevel) || 'aal1'; + } catch (error) { + console.error('[AuthService] Exception getting AAL:', error); + return 'aal1'; + } +} + +/** + * Get enrolled MFA factors for the current user + */ +export async function getEnrolledFactors(): Promise { + try { + const { data, error } = await supabase.auth.mfa.listFactors(); + + if (error) { + console.error('[AuthService] Error listing factors:', error); + return []; + } + + return (data?.totp || []) + .filter(f => f.status === 'verified') + .map(f => ({ + id: f.id, + factor_type: 'totp' as const, + status: 'verified' as const, + friendly_name: f.friendly_name, + created_at: f.created_at, + updated_at: f.updated_at, + })); + } catch (error) { + console.error('[AuthService] Exception listing factors:', error); + return []; + } +} + +/** + * Check if user needs AAL step-up + * Returns detailed information about enrollment and current AAL level + */ +export async function checkAalStepUp(session: Session | null): Promise { + if (!session?.user) { + return { + needsStepUp: false, + hasMfaEnrolled: false, + currentLevel: 'aal1', + hasEnrolledFactors: false, + }; + } + + const [currentLevel, factors] = await Promise.all([ + getSessionAal(session), + getEnrolledFactors(), + ]); + + const hasEnrolledFactors = factors.length > 0; + const needsStepUp = hasEnrolledFactors && currentLevel === 'aal1'; + + return { + needsStepUp, + hasMfaEnrolled: hasEnrolledFactors, + currentLevel, + hasEnrolledFactors, + factorId: factors[0]?.id, + }; +} + +/** + * Verify MFA is required for a user based on their role + */ +export async function verifyMfaRequired(userId: string): Promise { + try { + const { data, error } = await supabase + .from('user_roles') + .select('role') + .eq('user_id', userId) + .in('role', ['admin', 'moderator']); + + if (error) { + console.error('[AuthService] Error checking roles:', error); + return false; + } + + return (data?.length || 0) > 0; + } catch (error) { + console.error('[AuthService] Exception checking roles:', error); + return false; + } +} + +/** + * Handle post-authentication flow for all auth methods + * Detects if MFA step-up is needed and redirects accordingly + */ +export async function handlePostAuthFlow( + session: Session, + authMethod: 'password' | 'oauth' | 'magiclink' +): Promise> { + try { + // Store auth method for audit logging + setAuthMethod(authMethod); + + // Check if step-up is needed + const aalCheck = await checkAalStepUp(session); + + if (aalCheck.needsStepUp) { + console.log(`[AuthService] ${authMethod} sign-in requires MFA step-up`); + + // Set flag and redirect to step-up page + setStepUpRequired(true, window.location.pathname); + + // Log audit event + await logAuthEvent(session.user.id, 'mfa_step_up_required', { + auth_method: authMethod, + current_aal: aalCheck.currentLevel, + }); + + return { + success: true, + data: { + shouldRedirect: true, + redirectTo: '/auth/mfa-step-up', + }, + }; + } + + // Log successful authentication + await logAuthEvent(session.user.id, 'authentication_success', { + auth_method: authMethod, + aal: aalCheck.currentLevel, + }); + + return { + success: true, + data: { + shouldRedirect: false, + }, + }; + } catch (error) { + console.error('[AuthService] Error in post-auth flow:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * Verify MFA challenge was successful and session upgraded to AAL2 + */ +export async function verifyMfaUpgrade(session: Session | null): Promise { + if (!session) { + return { + success: false, + error: 'No session found', + }; + } + + const currentAal = await getSessionAal(session); + + if (currentAal !== 'aal2') { + console.error('[AuthService] MFA verification failed - still at AAL1'); + await logAuthEvent(session.user.id, 'mfa_verification_failed', { + expected_aal: 'aal2', + actual_aal: currentAal, + }); + + return { + success: false, + error: 'Failed to upgrade session to AAL2', + newAal: currentAal, + }; + } + + // Log successful upgrade + await logAuthEvent(session.user.id, 'mfa_verification_success', { + new_aal: currentAal, + }); + + // Clear auth flags + clearAllAuthFlags(); + + return { + success: true, + newAal: currentAal, + }; +} + +/** + * Log authentication event to audit log + */ +async function logAuthEvent( + userId: string, + action: string, + details: Record +): Promise { + try { + const { error } = await supabase.rpc('log_admin_action', { + _admin_user_id: userId, + _action: action, + _target_user_id: userId, + _details: details, + }); + + if (error) { + console.error('[AuthService] Error logging auth event:', error); + } + } catch (error) { + console.error('[AuthService] Exception logging auth event:', error); + } +} + +/** + * Handle sign out with proper cleanup + */ +export async function signOutUser(): Promise { + try { + const { error } = await supabase.auth.signOut(); + + if (error) { + return { + success: false, + error: error.message, + }; + } + + // Clear all session flags + clearAllAuthFlags(); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/src/lib/sessionFlags.ts b/src/lib/sessionFlags.ts new file mode 100644 index 00000000..24f9b0a6 --- /dev/null +++ b/src/lib/sessionFlags.ts @@ -0,0 +1,83 @@ +/** + * Type-safe session storage management for authentication flows + */ + +export const SessionFlags = { + MFA_STEP_UP_REQUIRED: 'mfa_step_up_required', + MFA_INTENDED_PATH: 'mfa_intended_path', + MFA_CHALLENGE_ID: 'mfa_challenge_id', + AUTH_METHOD: 'auth_method', +} as const; + +export type SessionFlagKey = typeof SessionFlags[keyof typeof SessionFlags]; + +/** + * Set the MFA step-up required flag + */ +export function setStepUpRequired(required: boolean, intendedPath?: string): void { + if (required) { + sessionStorage.setItem(SessionFlags.MFA_STEP_UP_REQUIRED, 'true'); + if (intendedPath) { + sessionStorage.setItem(SessionFlags.MFA_INTENDED_PATH, intendedPath); + } + } else { + clearStepUpFlags(); + } +} + +/** + * Check if MFA step-up is required + */ +export function getStepUpRequired(): boolean { + return sessionStorage.getItem(SessionFlags.MFA_STEP_UP_REQUIRED) === 'true'; +} + +/** + * Get the intended path after MFA verification + */ +export function getIntendedPath(): string { + return sessionStorage.getItem(SessionFlags.MFA_INTENDED_PATH) || '/'; +} + +/** + * Clear all MFA step-up flags + */ +export function clearStepUpFlags(): void { + sessionStorage.removeItem(SessionFlags.MFA_STEP_UP_REQUIRED); + sessionStorage.removeItem(SessionFlags.MFA_INTENDED_PATH); + sessionStorage.removeItem(SessionFlags.MFA_CHALLENGE_ID); +} + +/** + * Store the authentication method used + */ +export function setAuthMethod(method: 'password' | 'oauth' | 'magiclink'): void { + sessionStorage.setItem(SessionFlags.AUTH_METHOD, method); +} + +/** + * Get the authentication method used + */ +export function getAuthMethod(): 'password' | 'oauth' | 'magiclink' | null { + const method = sessionStorage.getItem(SessionFlags.AUTH_METHOD); + if (method === 'password' || method === 'oauth' || method === 'magiclink') { + return method; + } + return null; +} + +/** + * Clear the authentication method + */ +export function clearAuthMethod(): void { + sessionStorage.removeItem(SessionFlags.AUTH_METHOD); +} + +/** + * Clear all authentication-related session flags + */ +export function clearAllAuthFlags(): void { + Object.values(SessionFlags).forEach(flag => { + sessionStorage.removeItem(flag); + }); +} diff --git a/src/pages/AuthCallback.tsx b/src/pages/AuthCallback.tsx index 1c80f3d2..c03e3aca 100644 --- a/src/pages/AuthCallback.tsx +++ b/src/pages/AuthCallback.tsx @@ -4,6 +4,8 @@ import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; import { Loader2 } from 'lucide-react'; import { Header } from '@/components/layout/Header'; +import { handlePostAuthFlow } from '@/lib/authService'; +import type { AuthMethod } from '@/types/auth'; export default function AuthCallback() { const navigate = useNavigate(); @@ -71,31 +73,22 @@ export default function AuthCallback() { } } - // Check if MFA step-up is required for OAuth users + // Determine authentication method + let authMethod: AuthMethod = 'magiclink'; if (isOAuthUser) { - console.log('[AuthCallback] Checking MFA requirements for OAuth user...'); - - try { - const { data: factors } = await supabase.auth.mfa.listFactors(); - const hasMfaEnrolled = factors?.totp?.some(f => f.status === 'verified'); - - const { data: { currentLevel } } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); - - console.log('[AuthCallback] MFA status:', { - hasMfaEnrolled, - currentLevel, - }); + authMethod = 'oauth'; + } + + console.log('[AuthCallback] Auth method:', authMethod); - if (hasMfaEnrolled && currentLevel === 'aal1') { - console.log('[AuthCallback] MFA step-up required, redirecting...'); - sessionStorage.setItem('mfa_step_up_required', 'true'); - navigate('/auth/mfa-step-up'); - return; - } - } catch (error) { - console.error('[AuthCallback] Failed to check MFA status:', error); - // Continue anyway - don't block sign-in - } + // Unified post-authentication flow for ALL methods (OAuth, magic link, etc.) + console.log('[AuthCallback] Running post-auth flow...'); + const result = await handlePostAuthFlow(session, authMethod); + + if (result.success && result.data?.shouldRedirect) { + console.log('[AuthCallback] Redirecting to:', result.data.redirectTo); + navigate(result.data.redirectTo); + return; } setStatus('success'); diff --git a/src/pages/MFAStepUp.tsx b/src/pages/MFAStepUp.tsx index 6005112a..aff0ee7b 100644 --- a/src/pages/MFAStepUp.tsx +++ b/src/pages/MFAStepUp.tsx @@ -5,6 +5,8 @@ import { useToast } from '@/hooks/use-toast'; import { Header } from '@/components/layout/Header'; import { MFAChallenge } from '@/components/auth/MFAChallenge'; import { Shield } from 'lucide-react'; +import { getStepUpRequired, getIntendedPath, clearStepUpFlags } from '@/lib/sessionFlags'; +import { getEnrolledFactors } from '@/lib/authService'; export default function MFAStepUp() { const navigate = useNavigate(); @@ -14,30 +16,28 @@ export default function MFAStepUp() { useEffect(() => { const checkStepUpRequired = async () => { // Check if this page was accessed via proper flow - const needsStepUp = sessionStorage.getItem('mfa_step_up_required'); - if (!needsStepUp) { + if (!getStepUpRequired()) { console.log('[MFAStepUp] No step-up flag found, redirecting to auth'); navigate('/auth'); return; } - // Get the verified TOTP factor - const { data: factors } = await supabase.auth.mfa.listFactors(); - const totpFactor = factors?.totp?.find(f => f.status === 'verified'); + // Get enrolled MFA factors + const factors = await getEnrolledFactors(); - if (!totpFactor) { + if (factors.length === 0) { console.log('[MFAStepUp] No verified TOTP factor found'); toast({ variant: 'destructive', title: 'MFA not enrolled', description: 'Please enroll in two-factor authentication first.', }); - sessionStorage.removeItem('mfa_step_up_required'); + clearStepUpFlags(); navigate('/settings?tab=security'); return; } - setFactorId(totpFactor.id); + setFactorId(factors[0].id); }; checkStepUpRequired(); @@ -46,33 +46,26 @@ export default function MFAStepUp() { const handleSuccess = async () => { console.log('[MFAStepUp] MFA verification successful'); - // Clear the step-up flag - sessionStorage.removeItem('mfa_step_up_required'); - toast({ title: 'Verification successful', description: 'You now have full access to all features.', }); // Redirect to home or intended destination - const intendedPath = sessionStorage.getItem('mfa_intended_path') || '/'; - sessionStorage.removeItem('mfa_intended_path'); + const intendedPath = getIntendedPath(); + clearStepUpFlags(); navigate(intendedPath); }; const handleCancel = async () => { console.log('[MFAStepUp] MFA verification cancelled'); - // Clear flags - sessionStorage.removeItem('mfa_step_up_required'); - sessionStorage.removeItem('mfa_intended_path'); - - // Sign out for security - await supabase.auth.signOut(); + // Clear flags and redirect to sign-in (less harsh than forcing sign-out) + clearStepUpFlags(); toast({ title: 'Verification cancelled', - description: 'You have been signed out.', + description: 'Please sign in again to continue.', }); navigate('/auth'); diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 00000000..d8d601e9 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,74 @@ +import type { Session, User } from '@supabase/supabase-js'; + +/** + * Authenticator Assurance Levels (AAL) + * - aal1: Basic authentication (password/OAuth/magic link) + * - aal2: Multi-factor authentication completed + */ +export type AALLevel = 'aal1' | 'aal2'; + +/** + * MFA Factor types supported by Supabase + */ +export type MFAFactorType = 'totp'; + +/** + * MFA Factor status + */ +export type MFAFactorStatus = 'verified' | 'unverified'; + +/** + * MFA Factor structure from Supabase + */ +export interface MFAFactor { + id: string; + factor_type: MFAFactorType; + status: MFAFactorStatus; + friendly_name?: string; + created_at: string; + updated_at: string; +} + +/** + * Result of AAL step-up check + */ +export interface CheckAalResult { + needsStepUp: boolean; + hasMfaEnrolled: boolean; + currentLevel: AALLevel | null; + hasEnrolledFactors?: boolean; + factorId?: string; +} + +/** + * Authentication method types + */ +export type AuthMethod = 'password' | 'oauth' | 'magiclink'; + +/** + * Authentication session with AAL information + */ +export interface AuthSessionInfo { + session: Session | null; + user: User | null; + aal: AALLevel; + authMethod?: AuthMethod; +} + +/** + * MFA Challenge result + */ +export interface MFAChallengeResult { + success: boolean; + error?: string; + newAal?: AALLevel; +} + +/** + * Auth service response + */ +export interface AuthServiceResponse { + success: boolean; + data?: T; + error?: string; +}