diff --git a/src/App.tsx b/src/App.tsx index 8593236f..564ee1a3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,10 @@ import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { I18nextProvider } from 'react-i18next'; import { ThemeProvider } from './contexts/ThemeContext'; -import { AuthProvider } from './contexts/AuthContext'; +import { AuthProvider, ProtectedRoute } from './components/auth'; import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; import { WebSocketProvider } from './contexts/WebSocketContext'; -import ProtectedRoute from './components/ProtectedRoute'; import AppContent from './components/app/AppContent'; import i18n from './i18n/config.js'; diff --git a/src/components/LoginForm.jsx b/src/components/LoginForm.jsx deleted file mode 100644 index 1482ad7d..00000000 --- a/src/components/LoginForm.jsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useState } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { MessageSquare } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -const LoginForm = () => { - const { t } = useTranslation('auth'); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - - const { login } = useAuth(); - - const handleSubmit = async (e) => { - e.preventDefault(); - setError(''); - - if (!username || !password) { - setError(t('errors.requiredFields')); - return; - } - - setIsLoading(true); - - const result = await login(username, password); - - if (!result.success) { - setError(result.error); - } - - setIsLoading(false); - }; - - return ( -
-
-
- {/* Logo and Title */} -
-
-
- -
-
-

{t('login.title')}

-

- {t('login.description')} -

-
- - {/* Login Form */} -
-
- - setUsername(e.target.value)} - className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder={t('login.placeholders.username')} - required - disabled={isLoading} - /> -
- -
- - setPassword(e.target.value)} - className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder={t('login.placeholders.password')} - required - disabled={isLoading} - /> -
- - {error && ( -
-

{error}

-
- )} - - -
- -
-

- Enter your credentials to access Claude Code UI -

-
-
-
-
- ); -}; - -export default LoginForm; \ No newline at end of file diff --git a/src/components/Onboarding.jsx b/src/components/Onboarding.jsx index 15fed04c..556e3c77 100644 --- a/src/components/Onboarding.jsx +++ b/src/components/Onboarding.jsx @@ -3,7 +3,7 @@ import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, Externa import SessionProviderLogo from './llm-logo-provider/SessionProviderLogo'; import LoginModal from './LoginModal'; import { authenticatedFetch } from '../utils/api'; -import { useAuth } from '../contexts/AuthContext'; +import { useAuth } from './auth/context/AuthContext'; import { IS_PLATFORM } from '../constants/config'; const Onboarding = ({ onComplete }) => { diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx deleted file mode 100644 index 507efa87..00000000 --- a/src/components/ProtectedRoute.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import SetupForm from './SetupForm'; -import LoginForm from './LoginForm'; -import Onboarding from './Onboarding'; -import { MessageSquare } from 'lucide-react'; -import { IS_PLATFORM } from '../constants/config'; - -const LoadingScreen = () => ( -
-
-
-
- -
-
-

Claude Code UI

-
-
-
-
-
-

Loading...

-
-
-); - -const ProtectedRoute = ({ children }) => { - const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth(); - - if (IS_PLATFORM) { - if (isLoading) { - return ; - } - - if (!hasCompletedOnboarding) { - return ; - } - - return children; - } - - if (isLoading) { - return ; - } - - if (needsSetup) { - return ; - } - - if (!user) { - return ; - } - - if (!hasCompletedOnboarding) { - return ; - } - - return children; -}; - -export default ProtectedRoute; \ No newline at end of file diff --git a/src/components/SetupForm.jsx b/src/components/SetupForm.jsx deleted file mode 100644 index 4f41d0b7..00000000 --- a/src/components/SetupForm.jsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { useState } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -const SetupForm = () => { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - - const { register } = useAuth(); - - const handleSubmit = async (e) => { - e.preventDefault(); - setError(''); - - if (password !== confirmPassword) { - setError('Passwords do not match'); - return; - } - - if (username.length < 3) { - setError('Username must be at least 3 characters long'); - return; - } - - if (password.length < 6) { - setError('Password must be at least 6 characters long'); - return; - } - - setIsLoading(true); - - const result = await register(username, password); - - if (!result.success) { - setError(result.error); - } - - setIsLoading(false); - }; - - return ( -
-
-
- {/* Logo and Title */} -
-
- CloudCLI -
-

Welcome to Claude Code UI

-

- Set up your account to get started -

-
- - {/* Setup Form */} -
-
- - setUsername(e.target.value)} - className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="Enter your username" - required - disabled={isLoading} - /> -
- -
- - setPassword(e.target.value)} - className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="Enter your password" - required - disabled={isLoading} - /> -
- -
- - setConfirmPassword(e.target.value)} - className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="Confirm your password" - required - disabled={isLoading} - /> -
- - {error && ( -
-

{error}

-
- )} - - -
- -
-

- This is a single-user system. Only one account can be created. -

-
-
-
-
- ); -}; - -export default SetupForm; \ No newline at end of file diff --git a/src/components/auth/constants.ts b/src/components/auth/constants.ts new file mode 100644 index 00000000..37226b02 --- /dev/null +++ b/src/components/auth/constants.ts @@ -0,0 +1,8 @@ +export const AUTH_TOKEN_STORAGE_KEY = 'auth-token'; + +export const AUTH_ERROR_MESSAGES = { + authStatusCheckFailed: 'Failed to check authentication status', + loginFailed: 'Login failed', + registrationFailed: 'Registration failed', + networkError: 'Network error. Please try again.', +} as const; diff --git a/src/components/auth/context/AuthContext.tsx b/src/components/auth/context/AuthContext.tsx new file mode 100644 index 00000000..69bdd8a5 --- /dev/null +++ b/src/components/auth/context/AuthContext.tsx @@ -0,0 +1,222 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { IS_PLATFORM } from '../../../constants/config'; +import { api } from '../../../utils/api'; +import { AUTH_ERROR_MESSAGES, AUTH_TOKEN_STORAGE_KEY } from '../constants'; +import type { + AuthContextValue, + AuthProviderProps, + AuthSessionPayload, + AuthStatusPayload, + AuthUser, + AuthUserPayload, + OnboardingStatusPayload, +} from '../types'; +import { parseJsonSafely, resolveApiErrorMessage } from '../utils'; + +const AuthContext = createContext(null); + +const readStoredToken = (): string | null => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY); + +const persistToken = (token: string) => { + localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token); +}; + +const clearStoredToken = () => { + localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); +}; + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + + return context; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(() => readStoredToken()); + const [isLoading, setIsLoading] = useState(true); + const [needsSetup, setNeedsSetup] = useState(false); + const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true); + const [error, setError] = useState(null); + + const setSession = useCallback((nextUser: AuthUser, nextToken: string) => { + setUser(nextUser); + setToken(nextToken); + persistToken(nextToken); + }, []); + + const clearSession = useCallback(() => { + setUser(null); + setToken(null); + clearStoredToken(); + }, []); + + const checkOnboardingStatus = useCallback(async () => { + try { + const response = await api.user.onboardingStatus(); + if (!response.ok) { + return; + } + + const payload = await parseJsonSafely(response); + setHasCompletedOnboarding(Boolean(payload?.hasCompletedOnboarding)); + } catch (caughtError) { + console.error('Error checking onboarding status:', caughtError); + // Fail open to avoid blocking access on transient onboarding status errors. + setHasCompletedOnboarding(true); + } + }, []); + + const refreshOnboardingStatus = useCallback(async () => { + await checkOnboardingStatus(); + }, [checkOnboardingStatus]); + + const checkAuthStatus = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + + const statusResponse = await api.auth.status(); + const statusPayload = await parseJsonSafely(statusResponse); + + if (statusPayload?.needsSetup) { + setNeedsSetup(true); + return; + } + + setNeedsSetup(false); + + if (!token) { + return; + } + + const userResponse = await api.auth.user(); + if (!userResponse.ok) { + clearSession(); + return; + } + + const userPayload = await parseJsonSafely(userResponse); + if (!userPayload?.user) { + clearSession(); + return; + } + + setUser(userPayload.user); + await checkOnboardingStatus(); + } catch (caughtError) { + console.error('[Auth] Auth status check failed:', caughtError); + setError(AUTH_ERROR_MESSAGES.authStatusCheckFailed); + } finally { + setIsLoading(false); + } + }, [checkOnboardingStatus, clearSession, token]); + + useEffect(() => { + if (IS_PLATFORM) { + setUser({ username: 'platform-user' }); + setNeedsSetup(false); + void checkOnboardingStatus().finally(() => { + setIsLoading(false); + }); + return; + } + + void checkAuthStatus(); + }, [checkAuthStatus, checkOnboardingStatus]); + + const login = useCallback( + async (username, password) => { + try { + setError(null); + const response = await api.auth.login(username, password); + const payload = await parseJsonSafely(response); + + if (!response.ok || !payload?.token || !payload.user) { + const message = resolveApiErrorMessage(payload, AUTH_ERROR_MESSAGES.loginFailed); + setError(message); + return { success: false, error: message }; + } + + setSession(payload.user, payload.token); + setNeedsSetup(false); + await checkOnboardingStatus(); + return { success: true }; + } catch (caughtError) { + console.error('Login error:', caughtError); + setError(AUTH_ERROR_MESSAGES.networkError); + return { success: false, error: AUTH_ERROR_MESSAGES.networkError }; + } + }, + [checkOnboardingStatus, setSession], + ); + + const register = useCallback( + async (username, password) => { + try { + setError(null); + const response = await api.auth.register(username, password); + const payload = await parseJsonSafely(response); + + if (!response.ok || !payload?.token || !payload.user) { + const message = resolveApiErrorMessage(payload, AUTH_ERROR_MESSAGES.registrationFailed); + setError(message); + return { success: false, error: message }; + } + + setSession(payload.user, payload.token); + setNeedsSetup(false); + await checkOnboardingStatus(); + return { success: true }; + } catch (caughtError) { + console.error('Registration error:', caughtError); + setError(AUTH_ERROR_MESSAGES.networkError); + return { success: false, error: AUTH_ERROR_MESSAGES.networkError }; + } + }, + [checkOnboardingStatus, setSession], + ); + + const logout = useCallback(() => { + const tokenToInvalidate = token; + clearSession(); + + if (tokenToInvalidate) { + void api.auth.logout().catch((caughtError: unknown) => { + console.error('Logout endpoint error:', caughtError); + }); + } + }, [clearSession, token]); + + const contextValue = useMemo( + () => ({ + user, + token, + isLoading, + needsSetup, + hasCompletedOnboarding, + error, + login, + register, + logout, + refreshOnboardingStatus, + }), + [ + error, + hasCompletedOnboarding, + isLoading, + login, + logout, + needsSetup, + refreshOnboardingStatus, + register, + token, + user, + ], + ); + + return {children}; +} diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts new file mode 100644 index 00000000..62659ecf --- /dev/null +++ b/src/components/auth/index.ts @@ -0,0 +1,2 @@ +export { AuthProvider, useAuth } from './context/AuthContext'; +export { default as ProtectedRoute } from './view/ProtectedRoute'; diff --git a/src/components/auth/types.ts b/src/components/auth/types.ts new file mode 100644 index 00000000..e745a372 --- /dev/null +++ b/src/components/auth/types.ts @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; + +export type AuthUser = { + id?: number | string; + username: string; + [key: string]: unknown; +}; + +export type AuthActionResult = { success: true } | { success: false; error: string }; + +export type AuthSessionPayload = { + token?: string; + user?: AuthUser; + error?: string; + message?: string; +}; + +export type AuthStatusPayload = { + needsSetup?: boolean; +}; + +export type AuthUserPayload = { + user?: AuthUser; +}; + +export type OnboardingStatusPayload = { + hasCompletedOnboarding?: boolean; +}; + +export type ApiErrorPayload = { + error?: string; + message?: string; +}; + +export type AuthContextValue = { + user: AuthUser | null; + token: string | null; + isLoading: boolean; + needsSetup: boolean; + hasCompletedOnboarding: boolean; + error: string | null; + login: (username: string, password: string) => Promise; + register: (username: string, password: string) => Promise; + logout: () => void; + refreshOnboardingStatus: () => Promise; +}; + +export type AuthProviderProps = { + children: ReactNode; +}; diff --git a/src/components/auth/utils.ts b/src/components/auth/utils.ts new file mode 100644 index 00000000..152a986c --- /dev/null +++ b/src/components/auth/utils.ts @@ -0,0 +1,17 @@ +import type { ApiErrorPayload } from './types'; + +export async function parseJsonSafely(response: Response): Promise { + try { + return (await response.json()) as T; + } catch { + return null; + } +} + +export function resolveApiErrorMessage(payload: ApiErrorPayload | null, fallback: string): string { + if (!payload) { + return fallback; + } + + return payload.error ?? payload.message ?? fallback; +} diff --git a/src/components/auth/view/AuthErrorAlert.tsx b/src/components/auth/view/AuthErrorAlert.tsx new file mode 100644 index 00000000..8bc90f27 --- /dev/null +++ b/src/components/auth/view/AuthErrorAlert.tsx @@ -0,0 +1,15 @@ +type AuthErrorAlertProps = { + errorMessage: string; +}; + +export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) { + if (!errorMessage) { + return null; + } + + return ( +
+

{errorMessage}

+
+ ); +} diff --git a/src/components/auth/view/AuthInputField.tsx b/src/components/auth/view/AuthInputField.tsx new file mode 100644 index 00000000..3f1ff1a5 --- /dev/null +++ b/src/components/auth/view/AuthInputField.tsx @@ -0,0 +1,37 @@ +type AuthInputFieldProps = { + id: string; + label: string; + value: string; + onChange: (nextValue: string) => void; + placeholder: string; + isDisabled: boolean; + type?: 'text' | 'password' | 'email'; +}; + +export default function AuthInputField({ + id, + label, + value, + onChange, + placeholder, + isDisabled, + type = 'text', +}: AuthInputFieldProps) { + return ( +
+ + onChange(event.target.value)} + className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder={placeholder} + required + disabled={isDisabled} + /> +
+ ); +} diff --git a/src/components/auth/view/AuthLoadingScreen.tsx b/src/components/auth/view/AuthLoadingScreen.tsx new file mode 100644 index 00000000..28702385 --- /dev/null +++ b/src/components/auth/view/AuthLoadingScreen.tsx @@ -0,0 +1,31 @@ +import { MessageSquare } from 'lucide-react'; + +const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s']; + +export default function AuthLoadingScreen() { + return ( +
+
+
+
+ +
+
+ +

Claude Code UI

+ +
+ {loadingDotAnimationDelays.map((delay) => ( +
+ ))} +
+ +

Loading...

+
+
+ ); +} diff --git a/src/components/auth/view/AuthScreenLayout.tsx b/src/components/auth/view/AuthScreenLayout.tsx new file mode 100644 index 00000000..871ce514 --- /dev/null +++ b/src/components/auth/view/AuthScreenLayout.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; +import { MessageSquare } from 'lucide-react'; + +type AuthScreenLayoutProps = { + title: string; + description: string; + children: ReactNode; + footerText: string; + logo?: ReactNode; +}; + +export default function AuthScreenLayout({ + title, + description, + children, + footerText, + logo, +}: AuthScreenLayoutProps) { + return ( +
+
+
+
+
+ {logo ?? ( +
+ +
+ )} +
+

{title}

+

{description}

+
+ + {children} + +
+

{footerText}

+
+
+
+
+ ); +} diff --git a/src/components/auth/view/LoginForm.tsx b/src/components/auth/view/LoginForm.tsx new file mode 100644 index 00000000..c3529543 --- /dev/null +++ b/src/components/auth/view/LoginForm.tsx @@ -0,0 +1,90 @@ +import { useCallback, useState } from 'react'; +import type { FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '../context/AuthContext'; +import AuthErrorAlert from './AuthErrorAlert'; +import AuthInputField from './AuthInputField'; +import AuthScreenLayout from './AuthScreenLayout'; + +type LoginFormState = { + username: string; + password: string; +}; + +const initialState: LoginFormState = { + username: '', + password: '', +}; + +export default function LoginForm() { + const { t } = useTranslation('auth'); + const { login } = useAuth(); + + const [formState, setFormState] = useState(initialState); + const [errorMessage, setErrorMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const updateField = useCallback((field: keyof LoginFormState, value: string) => { + setFormState((previous) => ({ ...previous, [field]: value })); + }, []); + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + setErrorMessage(''); + + // Keep form validation local so each auth screen owns its own UI feedback. + if (!formState.username.trim() || !formState.password) { + setErrorMessage(t('login.errors.requiredFields')); + return; + } + + setIsSubmitting(true); + const result = await login(formState.username.trim(), formState.password); + if (!result.success) { + setErrorMessage(result.error); + } + setIsSubmitting(false); + }, + [formState.password, formState.username, login, t], + ); + + return ( + +
+ updateField('username', value)} + placeholder={t('login.placeholders.username')} + isDisabled={isSubmitting} + /> + + updateField('password', value)} + placeholder={t('login.placeholders.password')} + isDisabled={isSubmitting} + type="password" + /> + + + + + +
+ ); +} diff --git a/src/components/auth/view/ProtectedRoute.tsx b/src/components/auth/view/ProtectedRoute.tsx new file mode 100644 index 00000000..10431e5c --- /dev/null +++ b/src/components/auth/view/ProtectedRoute.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; +import Onboarding from '../../Onboarding'; +import { IS_PLATFORM } from '../../../constants/config'; +import { useAuth } from '../context/AuthContext'; +import AuthLoadingScreen from './AuthLoadingScreen'; +import LoginForm from './LoginForm'; +import SetupForm from './SetupForm'; + +type ProtectedRouteProps = { + children: ReactNode; +}; + +export default function ProtectedRoute({ children }: ProtectedRouteProps) { + const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth(); + + if (isLoading) { + return ; + } + + if (IS_PLATFORM) { + if (!hasCompletedOnboarding) { + return ; + } + + return <>{children}; + } + + if (needsSetup) { + return ; + } + + if (!user) { + return ; + } + + if (!hasCompletedOnboarding) { + return ; + } + + return <>{children}; +} diff --git a/src/components/auth/view/SetupForm.tsx b/src/components/auth/view/SetupForm.tsx new file mode 100644 index 00000000..f4d892d1 --- /dev/null +++ b/src/components/auth/view/SetupForm.tsx @@ -0,0 +1,121 @@ +import { useCallback, useState } from 'react'; +import type { FormEvent } from 'react'; +import { useAuth } from '../context/AuthContext'; +import AuthErrorAlert from './AuthErrorAlert'; +import AuthInputField from './AuthInputField'; +import AuthScreenLayout from './AuthScreenLayout'; + +type SetupFormState = { + username: string; + password: string; + confirmPassword: string; +}; + +const initialState: SetupFormState = { + username: '', + password: '', + confirmPassword: '', +}; + +function validateSetupForm(formState: SetupFormState): string | null { + if (!formState.username.trim() || !formState.password || !formState.confirmPassword) { + return 'Please fill in all fields.'; + } + + if (formState.username.trim().length < 3) { + return 'Username must be at least 3 characters long.'; + } + + if (formState.password.length < 6) { + return 'Password must be at least 6 characters long.'; + } + + if (formState.password !== formState.confirmPassword) { + return 'Passwords do not match.'; + } + + return null; +} + +export default function SetupForm() { + const { register } = useAuth(); + + const [formState, setFormState] = useState(initialState); + const [errorMessage, setErrorMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const updateField = useCallback((field: keyof SetupFormState, value: string) => { + setFormState((previous) => ({ ...previous, [field]: value })); + }, []); + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + setErrorMessage(''); + + const validationError = validateSetupForm(formState); + if (validationError) { + setErrorMessage(validationError); + return; + } + + setIsSubmitting(true); + const result = await register(formState.username.trim(), formState.password); + if (!result.success) { + setErrorMessage(result.error); + } + setIsSubmitting(false); + }, + [formState, register], + ); + + return ( + } + > +
+ updateField('username', value)} + placeholder="Enter your username" + isDisabled={isSubmitting} + /> + + updateField('password', value)} + placeholder="Enter your password" + isDisabled={isSubmitting} + type="password" + /> + + updateField('confirmPassword', value)} + placeholder="Confirm your password" + isDisabled={isSubmitting} + type="password" + /> + + + + + +
+ ); +} diff --git a/src/components/task-master/context/TaskMasterContext.tsx b/src/components/task-master/context/TaskMasterContext.tsx index 01f6e300..37953ad8 100644 --- a/src/components/task-master/context/TaskMasterContext.tsx +++ b/src/components/task-master/context/TaskMasterContext.tsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../../../utils/api'; -import { useAuth } from '../../../contexts/AuthContext'; +import { useAuth } from '../../auth/context/AuthContext'; import { useWebSocket } from '../../../contexts/WebSocketContext'; import type { TaskMasterContextError, @@ -15,12 +15,6 @@ import type { const TaskMasterContext = createContext(null); -type AuthContextValue = { - user: unknown; - token: string | null; - isLoading: boolean; -}; - function createTaskMasterError(context: string, error: unknown): TaskMasterContextError { const message = error instanceof Error ? error.message : `Failed to ${context}`; return { @@ -64,7 +58,7 @@ export function useTaskMaster() { export function TaskMasterProvider({ children }: { children: React.ReactNode }) { const { latestMessage } = useWebSocket(); - const { user, token, isLoading: isAuthLoading } = useAuth() as AuthContextValue; + const { user, token, isLoading: isAuthLoading } = useAuth(); const [projects, setProjects] = useState([]); const [currentProject, setCurrentProjectState] = useState(null); diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index d974c7db..2673719d 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -1,189 +1 @@ -import React, { createContext, useContext, useEffect, useState } from 'react'; -import { api } from '../utils/api'; -import { IS_PLATFORM } from '../constants/config'; - -const AuthContext = createContext({ - user: null, - token: null, - login: () => {}, - register: () => {}, - logout: () => {}, - isLoading: true, - needsSetup: false, - hasCompletedOnboarding: true, - refreshOnboardingStatus: () => {}, - error: null -}); - -export const useAuth = () => { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -}; - -export const AuthProvider = ({ children }) => { - const [user, setUser] = useState(null); - const [token, setToken] = useState(localStorage.getItem('auth-token')); - const [isLoading, setIsLoading] = useState(true); - const [needsSetup, setNeedsSetup] = useState(false); - const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (IS_PLATFORM) { - setUser({ username: 'platform-user' }); - setNeedsSetup(false); - checkOnboardingStatus(); - setIsLoading(false); - return; - } - - checkAuthStatus(); - }, []); - - const checkOnboardingStatus = async () => { - try { - const response = await api.user.onboardingStatus(); - if (response.ok) { - const data = await response.json(); - setHasCompletedOnboarding(data.hasCompletedOnboarding); - } - } catch (error) { - console.error('Error checking onboarding status:', error); - setHasCompletedOnboarding(true); - } - }; - - const refreshOnboardingStatus = async () => { - await checkOnboardingStatus(); - }; - - const checkAuthStatus = async () => { - try { - setIsLoading(true); - setError(null); - - // Check if system needs setup - const statusResponse = await api.auth.status(); - const statusData = await statusResponse.json(); - - if (statusData.needsSetup) { - setNeedsSetup(true); - setIsLoading(false); - return; - } - - // If we have a token, verify it - if (token) { - try { - const userResponse = await api.auth.user(); - - if (userResponse.ok) { - const userData = await userResponse.json(); - setUser(userData.user); - setNeedsSetup(false); - await checkOnboardingStatus(); - } else { - // Token is invalid - localStorage.removeItem('auth-token'); - setToken(null); - setUser(null); - } - } catch (error) { - console.error('Token verification failed:', error); - localStorage.removeItem('auth-token'); - setToken(null); - setUser(null); - } - } - } catch (error) { - console.error('[AuthContext] Auth status check failed:', error); - setError('Failed to check authentication status'); - } finally { - setIsLoading(false); - } - }; - - const login = async (username, password) => { - try { - setError(null); - const response = await api.auth.login(username, password); - - const data = await response.json(); - - if (response.ok) { - setToken(data.token); - setUser(data.user); - localStorage.setItem('auth-token', data.token); - return { success: true }; - } else { - setError(data.error || 'Login failed'); - return { success: false, error: data.error || 'Login failed' }; - } - } catch (error) { - console.error('Login error:', error); - const errorMessage = 'Network error. Please try again.'; - setError(errorMessage); - return { success: false, error: errorMessage }; - } - }; - - const register = async (username, password) => { - try { - setError(null); - const response = await api.auth.register(username, password); - - const data = await response.json(); - - if (response.ok) { - setToken(data.token); - setUser(data.user); - setNeedsSetup(false); - localStorage.setItem('auth-token', data.token); - return { success: true }; - } else { - setError(data.error || 'Registration failed'); - return { success: false, error: data.error || 'Registration failed' }; - } - } catch (error) { - console.error('Registration error:', error); - const errorMessage = 'Network error. Please try again.'; - setError(errorMessage); - return { success: false, error: errorMessage }; - } - }; - - const logout = () => { - setToken(null); - setUser(null); - localStorage.removeItem('auth-token'); - - // Optional: Call logout endpoint for logging - if (token) { - api.auth.logout().catch(error => { - console.error('Logout endpoint error:', error); - }); - } - }; - - const value = { - user, - token, - login, - register, - logout, - isLoading, - needsSetup, - hasCompletedOnboarding, - refreshOnboardingStatus, - error - }; - - return ( - - {children} - - ); -}; \ No newline at end of file +export { AuthProvider, useAuth } from '../components/auth/context/AuthContext'; diff --git a/src/contexts/WebSocketContext.tsx b/src/contexts/WebSocketContext.tsx index ff680963..cd4e2a99 100644 --- a/src/contexts/WebSocketContext.tsx +++ b/src/contexts/WebSocketContext.tsx @@ -1,5 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { useAuth } from './AuthContext'; +import { useAuth } from '../components/auth/context/AuthContext'; import { IS_PLATFORM } from '../constants/config'; type WebSocketContextType = {