refactor(auth): migrate login and setup flows to typed feature module

- Introduce a new feature-based auth module under src/components/auth with clear separation of concerns:\n  - context/AuthContext.tsx for session lifecycle, onboarding status checks, token persistence, and auth actions\n  - view/* components for loading, route guarding, form layout, input fields, and error display\n  - shared auth constants, utility helpers, and type aliases (no interfaces)\n- Convert login and setup UIs to TypeScript and keep form state local to each component for readability and component-level ownership\n- Add explicit API payload typing and safe JSON parsing helpers to improve resilience when backend responses are malformed or incomplete\n- Centralize error fallback handling for auth requests to reduce repeated logic

- Replace legacy auth entrypoints with the new feature module in app wiring:\n  - App now imports AuthProvider and ProtectedRoute from src/components/auth\n  - WebSocketContext, TaskMasterContext, and Onboarding now consume useAuth from the new typed auth context\n- Remove duplicated legacy auth screens (LoginForm.jsx, SetupForm.jsx, ProtectedRoute.jsx)\n- Keep backward compatibility by turning src/contexts/AuthContext.jsx into a thin re-export of the new provider/hook

Result: auth code now follows a feature/domain structure, is fully typed, easier to navigate, and cleaner to extend without touching unrelated UI areas.
This commit is contained in:
Haileyesus
2026-03-02 18:33:10 +03:00
parent 06d8a4f2d1
commit b3ace59728
20 changed files with 684 additions and 508 deletions

View File

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

View File

@@ -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 (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground">{t('login.title')}</h1>
<p className="text-muted-foreground mt-2">
{t('login.description')}
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
{t('login.username')}
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => 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}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
{t('login.password')}
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => 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}
/>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? t('login.loading') : t('login.submit')}
</button>
</form>
<div className="text-center">
<p className="text-sm text-muted-foreground">
Enter your credentials to access Claude Code UI
</p>
</div>
</div>
</div>
</div>
);
};
export default LoginForm;

View File

@@ -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 }) => {

View File

@@ -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 = () => (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
<p className="text-muted-foreground mt-2">Loading...</p>
</div>
</div>
);
const ProtectedRoute = ({ children }) => {
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
if (IS_PLATFORM) {
if (isLoading) {
return <LoadingScreen />;
}
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return children;
}
if (isLoading) {
return <LoadingScreen />;
}
if (needsSetup) {
return <SetupForm />;
}
if (!user) {
return <LoginForm />;
}
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -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 (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<img src="/logo.svg" alt="CloudCLI" className="w-16 h-16" />
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome to Claude Code UI</h1>
<p className="text-muted-foreground mt-2">
Set up your account to get started
</p>
</div>
{/* Setup Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => 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}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => 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}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground mb-1">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => 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}
/>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? 'Setting up...' : 'Create Account'}
</button>
</form>
<div className="text-center">
<p className="text-sm text-muted-foreground">
This is a single-user system. Only one account can be created.
</p>
</div>
</div>
</div>
</div>
);
};
export default SetupForm;

View File

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

View File

@@ -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<AuthContextValue | null>(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<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(() => readStoredToken());
const [isLoading, setIsLoading] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true);
const [error, setError] = useState<string | null>(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<OnboardingStatusPayload>(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<AuthStatusPayload>(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<AuthUserPayload>(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<AuthContextValue['login']>(
async (username, password) => {
try {
setError(null);
const response = await api.auth.login(username, password);
const payload = await parseJsonSafely<AuthSessionPayload>(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<AuthContextValue['register']>(
async (username, password) => {
try {
setError(null);
const response = await api.auth.register(username, password);
const payload = await parseJsonSafely<AuthSessionPayload>(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<AuthContextValue>(
() => ({
user,
token,
isLoading,
needsSetup,
hasCompletedOnboarding,
error,
login,
register,
logout,
refreshOnboardingStatus,
}),
[
error,
hasCompletedOnboarding,
isLoading,
login,
logout,
needsSetup,
refreshOnboardingStatus,
register,
token,
user,
],
);
return <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>;
}

View File

@@ -0,0 +1,2 @@
export { AuthProvider, useAuth } from './context/AuthContext';
export { default as ProtectedRoute } from './view/ProtectedRoute';

View File

@@ -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<AuthActionResult>;
register: (username: string, password: string) => Promise<AuthActionResult>;
logout: () => void;
refreshOnboardingStatus: () => Promise<void>;
};
export type AuthProviderProps = {
children: ReactNode;
};

View File

@@ -0,0 +1,17 @@
import type { ApiErrorPayload } from './types';
export async function parseJsonSafely<T>(response: Response): Promise<T | null> {
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;
}

View File

@@ -0,0 +1,15 @@
type AuthErrorAlertProps = {
errorMessage: string;
};
export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
if (!errorMessage) {
return null;
}
return (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
</div>
);
}

View File

@@ -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 (
<div>
<label htmlFor={id} className="block text-sm font-medium text-foreground mb-1">
{label}
</label>
<input
id={id}
type={type}
value={value}
onChange={(event) => 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}
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { MessageSquare } from 'lucide-react';
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
export default function AuthLoadingScreen() {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2">
{loadingDotAnimationDelays.map((delay) => (
<div
key={delay}
className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
style={{ animationDelay: delay }}
/>
))}
</div>
<p className="text-muted-foreground mt-2">Loading...</p>
</div>
</div>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
<div className="text-center">
<div className="flex justify-center mb-4">
{logo ?? (
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
)}
</div>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
{children}
<div className="text-center">
<p className="text-sm text-muted-foreground">{footerText}</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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<LoginFormState>(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<HTMLFormElement>) => {
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 (
<AuthScreenLayout
title={t('login.title')}
description={t('login.description')}
footerText="Enter your credentials to access Claude Code UI"
>
<form onSubmit={handleSubmit} className="space-y-4">
<AuthInputField
id="username"
label={t('login.username')}
value={formState.username}
onChange={(value) => updateField('username', value)}
placeholder={t('login.placeholders.username')}
isDisabled={isSubmitting}
/>
<AuthInputField
id="password"
label={t('login.password')}
value={formState.password}
onChange={(value) => updateField('password', value)}
placeholder={t('login.placeholders.password')}
isDisabled={isSubmitting}
type="password"
/>
<AuthErrorAlert errorMessage={errorMessage} />
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isSubmitting ? t('login.loading') : t('login.submit')}
</button>
</form>
</AuthScreenLayout>
);
}

View File

@@ -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 <AuthLoadingScreen />;
}
if (IS_PLATFORM) {
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return <>{children}</>;
}
if (needsSetup) {
return <SetupForm />;
}
if (!user) {
return <LoginForm />;
}
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return <>{children}</>;
}

View File

@@ -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<SetupFormState>(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<HTMLFormElement>) => {
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 (
<AuthScreenLayout
title="Welcome to Claude Code UI"
description="Set up your account to get started"
footerText="This is a single-user system. Only one account can be created."
logo={<img src="/logo.svg" alt="CloudCLI" className="w-16 h-16" />}
>
<form onSubmit={handleSubmit} className="space-y-4">
<AuthInputField
id="username"
label="Username"
value={formState.username}
onChange={(value) => updateField('username', value)}
placeholder="Enter your username"
isDisabled={isSubmitting}
/>
<AuthInputField
id="password"
label="Password"
value={formState.password}
onChange={(value) => updateField('password', value)}
placeholder="Enter your password"
isDisabled={isSubmitting}
type="password"
/>
<AuthInputField
id="confirmPassword"
label="Confirm Password"
value={formState.confirmPassword}
onChange={(value) => updateField('confirmPassword', value)}
placeholder="Confirm your password"
isDisabled={isSubmitting}
type="password"
/>
<AuthErrorAlert errorMessage={errorMessage} />
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isSubmitting ? 'Setting up...' : 'Create Account'}
</button>
</form>
</AuthScreenLayout>
);
}

View File

@@ -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<TaskMasterContextValue | null>(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<TaskMasterProject[]>([]);
const [currentProject, setCurrentProjectState] = useState<TaskMasterProject | null>(null);

View File

@@ -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 (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
export { AuthProvider, useAuth } from '../components/auth/context/AuthContext';

View File

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