From 0181883c8a5852ffa90d1743f780a5365fb393e1 Mon Sep 17 00:00:00 2001 From: simos Date: Tue, 4 Nov 2025 08:26:31 +0000 Subject: [PATCH 1/3] feat(projects): add project creation wizard with enhanced UX Add new project creation wizard component with improved user experience and better input field interactions. Integrate projects API routes on the backend to support CRUD operations for project management. Changes include: - Add ProjectCreationWizard component with step-by-step project setup - Improve input hint visibility to hide when user starts typing - Refactor project creation state management in Sidebar component - Add ReactDOM import for portal-based wizard rendering The wizard provides a more intuitive onboarding experience for users creating new projects, while the enhanced input hints reduce visual clutter during active typing. --- server/index.js | 4 + server/routes/projects.js | 215 +++++++++ src/components/ChatInterface.jsx | 6 +- src/components/ProjectCreationWizard.jsx | 570 +++++++++++++++++++++++ src/components/Sidebar.jsx | 451 +----------------- src/contexts/AuthContext.jsx | 7 +- src/index.css | 2 +- src/utils/api.js | 5 + 8 files changed, 828 insertions(+), 432 deletions(-) create mode 100644 server/routes/projects.js create mode 100644 src/components/ProjectCreationWizard.jsx diff --git a/server/index.js b/server/index.js index 4562f80..28ba951 100755 --- a/server/index.js +++ b/server/index.js @@ -69,6 +69,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js'; import commandsRoutes from './routes/commands.js'; import settingsRoutes from './routes/settings.js'; import agentRoutes from './routes/agent.js'; +import projectsRoutes from './routes/projects.js'; import { initializeDatabase } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; @@ -201,6 +202,9 @@ app.use('/api', validateApiKey); // Authentication routes (public) app.use('/api/auth', authRoutes); +// Projects API Routes (protected) +app.use('/api/projects', authenticateToken, projectsRoutes); + // Git API Routes (protected) app.use('/api/git', authenticateToken, gitRoutes); diff --git a/server/routes/projects.js b/server/routes/projects.js new file mode 100644 index 0000000..665f094 --- /dev/null +++ b/server/routes/projects.js @@ -0,0 +1,215 @@ +import express from 'express'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; +import os from 'os'; +import { addProjectManually } from '../projects.js'; + +const router = express.Router(); + +/** + * Create a new workspace + * POST /api/projects/create-workspace + * + * Body: + * - workspaceType: 'existing' | 'new' + * - path: string (workspace path) + * - githubUrl?: string (optional, for new workspaces) + * - githubTokenId?: number (optional, ID of stored token) + * - newGithubToken?: string (optional, one-time token) + */ +router.post('/create-workspace', async (req, res) => { + try { + const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body; + + // Validate required fields + if (!workspaceType || !workspacePath) { + return res.status(400).json({ error: 'workspaceType and path are required' }); + } + + if (!['existing', 'new'].includes(workspaceType)) { + return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' }); + } + + const absolutePath = path.resolve(workspacePath); + + // Handle existing workspace + if (workspaceType === 'existing') { + // Check if the path exists + try { + await fs.access(absolutePath); + const stats = await fs.stat(absolutePath); + + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Path exists but is not a directory' }); + } + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ error: 'Workspace path does not exist' }); + } + throw error; + } + + // Add the existing workspace to the project list + const project = await addProjectManually(absolutePath); + + return res.json({ + success: true, + project, + message: 'Existing workspace added successfully' + }); + } + + // Handle new workspace creation + if (workspaceType === 'new') { + // Check if path already exists + try { + await fs.access(absolutePath); + return res.status(400).json({ + error: 'Path already exists. Please choose a different path or use "existing workspace" option.' + }); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + // Path doesn't exist - good, we can create it + } + + // Create the directory + await fs.mkdir(absolutePath, { recursive: true }); + + // If GitHub URL is provided, clone the repository + if (githubUrl) { + let githubToken = null; + + // Get GitHub token if needed + if (githubTokenId) { + // Fetch token from database + const token = await getGithubTokenById(githubTokenId, req.user.id); + if (!token) { + // Clean up created directory + await fs.rm(absolutePath, { recursive: true, force: true }); + return res.status(404).json({ error: 'GitHub token not found' }); + } + githubToken = token.github_token; + } else if (newGithubToken) { + githubToken = newGithubToken; + } + + // Clone the repository + try { + await cloneGitHubRepository(githubUrl, absolutePath, githubToken); + } catch (error) { + // Clean up created directory on failure + await fs.rm(absolutePath, { recursive: true, force: true }); + throw new Error(`Failed to clone repository: ${error.message}`); + } + } + + // Add the new workspace to the project list + const project = await addProjectManually(absolutePath); + + return res.json({ + success: true, + project, + message: githubUrl + ? 'New workspace created and repository cloned successfully' + : 'New workspace created successfully' + }); + } + + } catch (error) { + console.error('Error creating workspace:', error); + res.status(500).json({ + error: error.message || 'Failed to create workspace', + details: process.env.NODE_ENV === 'development' ? error.stack : undefined + }); + } +}); + +/** + * Helper function to get GitHub token from database + */ +async function getGithubTokenById(tokenId, userId) { + const { getDatabase } = await import('../database/db.js'); + const db = await getDatabase(); + + const token = await db.get( + 'SELECT * FROM github_tokens WHERE id = ? AND user_id = ? AND is_active = 1', + [tokenId, userId] + ); + + return token; +} + +/** + * Helper function to clone a GitHub repository + */ +function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) { + return new Promise((resolve, reject) => { + // Parse GitHub URL and inject token if provided + let cloneUrl = githubUrl; + + if (githubToken) { + try { + const url = new URL(githubUrl); + // Format: https://TOKEN@github.com/user/repo.git + url.username = githubToken; + url.password = ''; + cloneUrl = url.toString(); + } catch (error) { + return reject(new Error('Invalid GitHub URL format')); + } + } + + const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0' // Disable git password prompts + } + }); + + let stdout = ''; + let stderr = ''; + + gitProcess.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + gitProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + gitProcess.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + // Parse git error messages to provide helpful feedback + let errorMessage = 'Git clone failed'; + + if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) { + errorMessage = 'Authentication failed. Please check your GitHub token.'; + } else if (stderr.includes('Repository not found')) { + errorMessage = 'Repository not found. Please check the URL and ensure you have access.'; + } else if (stderr.includes('already exists')) { + errorMessage = 'Directory already exists'; + } else if (stderr) { + errorMessage = stderr; + } + + reject(new Error(errorMessage)); + } + }); + + gitProcess.on('error', (error) => { + if (error.code === 'ENOENT') { + reject(new Error('Git is not installed or not in PATH')); + } else { + reject(error); + } + }); + }); +} + +export default router; diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index e5afb0b..5ecd99a 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -4710,13 +4710,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess {/* Hint text inside input box at bottom */} -
+
{sendByCtrlEnter ? "Ctrl+Enter to send • Tab for modes • / for commands" diff --git a/src/components/ProjectCreationWizard.jsx b/src/components/ProjectCreationWizard.jsx new file mode 100644 index 0000000..c85d589 --- /dev/null +++ b/src/components/ProjectCreationWizard.jsx @@ -0,0 +1,570 @@ +import React, { useState, useEffect } from 'react'; +import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle } from 'lucide-react'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { api } from '../utils/api'; + +const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { + // Wizard state + const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm + const [workspaceType, setWorkspaceType] = useState(null); // 'existing' or 'new' + + // Form state + const [workspacePath, setWorkspacePath] = useState(''); + const [githubUrl, setGithubUrl] = useState(''); + const [selectedGithubToken, setSelectedGithubToken] = useState(''); + const [useExistingToken, setUseExistingToken] = useState(true); + const [newGithubToken, setNewGithubToken] = useState(''); + + // UI state + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + const [availableTokens, setAvailableTokens] = useState([]); + const [loadingTokens, setLoadingTokens] = useState(false); + const [pathSuggestions, setPathSuggestions] = useState([]); + const [showPathDropdown, setShowPathDropdown] = useState(false); + + // Load available GitHub tokens when needed + useEffect(() => { + if (step === 2 && workspaceType === 'new' && githubUrl) { + loadGithubTokens(); + } + }, [step, workspaceType, githubUrl]); + + // Load path suggestions + useEffect(() => { + if (workspacePath.length > 2) { + loadPathSuggestions(workspacePath); + } else { + setPathSuggestions([]); + setShowPathDropdown(false); + } + }, [workspacePath]); + + const loadGithubTokens = async () => { + try { + setLoadingTokens(true); + const response = await api.get('/settings/github-tokens'); + const data = await response.json(); + + const activeTokens = (data.tokens || []).filter(t => t.is_active); + setAvailableTokens(activeTokens); + + // Auto-select first token if available + if (activeTokens.length > 0 && !selectedGithubToken) { + setSelectedGithubToken(activeTokens[0].id.toString()); + } + } catch (error) { + console.error('Error loading GitHub tokens:', error); + } finally { + setLoadingTokens(false); + } + }; + + const loadPathSuggestions = async (inputPath) => { + try { + // Extract the directory to browse (parent of input) + const lastSlash = inputPath.lastIndexOf('/'); + const dirPath = lastSlash > 0 ? inputPath.substring(0, lastSlash) : '~'; + + const response = await api.browseFilesystem(dirPath); + const data = await response.json(); + + if (data.suggestions) { + // Filter suggestions based on the input + const filtered = data.suggestions.filter(s => + s.path.toLowerCase().startsWith(inputPath.toLowerCase()) + ); + setPathSuggestions(filtered.slice(0, 5)); + setShowPathDropdown(filtered.length > 0); + } + } catch (error) { + console.error('Error loading path suggestions:', error); + } + }; + + const handleNext = () => { + setError(null); + + if (step === 1) { + if (!workspaceType) { + setError('Please select whether you have an existing workspace or want to create a new one'); + return; + } + setStep(2); + } else if (step === 2) { + if (!workspacePath.trim()) { + setError('Please provide a workspace path'); + return; + } + + // No validation for GitHub token - it's optional (only needed for private repos) + setStep(3); + } + }; + + const handleBack = () => { + setError(null); + setStep(step - 1); + }; + + const handleCreate = async () => { + setIsCreating(true); + setError(null); + + try { + const payload = { + workspaceType, + path: workspacePath.trim(), + }; + + // Add GitHub info if creating new workspace with GitHub URL + if (workspaceType === 'new' && githubUrl) { + payload.githubUrl = githubUrl.trim(); + + if (useExistingToken && selectedGithubToken) { + payload.githubTokenId = parseInt(selectedGithubToken); + } else if (!useExistingToken && newGithubToken) { + payload.newGithubToken = newGithubToken.trim(); + } + } + + const response = await api.createWorkspace(payload); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to create workspace'); + } + + // Success! + if (onProjectCreated) { + onProjectCreated(data.project); + } + + onClose(); + } catch (error) { + console.error('Error creating workspace:', error); + setError(error.message || 'Failed to create workspace'); + } finally { + setIsCreating(false); + } + }; + + const selectPathSuggestion = (suggestion) => { + setWorkspacePath(suggestion.path); + setShowPathDropdown(false); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+

+ Create New Project +

+
+ +
+ + {/* Progress Indicator */} +
+
+ {[1, 2, 3].map((s) => ( + +
+
+ {s < step ? : s} +
+ + {s === 1 ? 'Type' : s === 2 ? 'Configure' : 'Confirm'} + +
+ {s < 3 && ( +
+ )} + + ))} +
+
+ + {/* Content */} +
+ {/* Error Display */} + {error && ( +
+ +
+

{error}

+
+
+ )} + + {/* Step 1: Choose workspace type */} + {step === 1 && ( +
+
+

+ Do you already have a workspace, or would you like to create a new one? +

+
+ {/* Existing Workspace */} + + + {/* New Workspace */} + +
+
+
+ )} + + {/* Step 2: Configure workspace */} + {step === 2 && ( +
+ {/* Workspace Path */} +
+ +
+ setWorkspacePath(e.target.value)} + placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'} + className="w-full" + /> + {showPathDropdown && pathSuggestions.length > 0 && ( +
+ {pathSuggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+

+ {workspaceType === 'existing' + ? 'Full path to your existing workspace directory' + : 'Full path where the new workspace will be created'} +

+
+ + {/* GitHub URL (only for new workspace) */} + {workspaceType === 'new' && ( + <> +
+ + setGithubUrl(e.target.value)} + placeholder="https://github.com/username/repository" + className="w-full" + /> +

+ Leave empty to create an empty workspace, or provide a GitHub URL to clone +

+
+ + {/* GitHub Token (only if GitHub URL is provided) */} + {githubUrl && ( +
+
+ +
+
+ GitHub Authentication (Optional) +
+

+ Only required for private repositories. Public repos can be cloned without authentication. +

+
+
+ + {loadingTokens ? ( +
+ + Loading stored tokens... +
+ ) : availableTokens.length > 0 ? ( + <> + {/* Token Selection Tabs */} +
+ + + +
+ + {useExistingToken ? ( +
+ + +
+ ) : ( +
+ + setNewGithubToken(e.target.value)} + placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + className="w-full" + /> +

+ This token will be used only for this operation +

+
+ )} + + ) : ( +
+
+

+ 💡 Public repositories don't require authentication. You can skip providing a token if cloning a public repo. +

+
+ +
+ + setNewGithubToken(e.target.value)} + placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)" + className="w-full" + /> +

+ No stored tokens available. You can add tokens in Settings → API Keys for easier reuse. +

+
+
+ )} +
+ )} + + )} +
+ )} + + {/* Step 3: Confirm */} + {step === 3 && ( +
+
+

+ Review Your Configuration +

+
+
+ Workspace Type: + + {workspaceType === 'existing' ? 'Existing Workspace' : 'New Workspace'} + +
+
+ Path: + + {workspacePath} + +
+ {workspaceType === 'new' && githubUrl && ( + <> +
+ Clone From: + + {githubUrl} + +
+
+ Authentication: + + {useExistingToken && selectedGithubToken + ? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.token_name || 'Unknown'}` + : newGithubToken + ? 'Using provided token' + : 'No authentication'} + +
+ + )} +
+
+ +
+

+ {workspaceType === 'existing' + ? 'The workspace will be added to your project list and will be available for Claude/Cursor sessions.' + : githubUrl + ? 'A new workspace will be created and the repository will be cloned from GitHub.' + : 'An empty workspace directory will be created at the specified path.'} +

+
+
+ )} +
+ + {/* Footer */} +
+ + + +
+
+
+ ); +}; + +export default ProjectCreationWizard; diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index cda6c5a..24e5bf7 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; +import ReactDOM from 'react-dom'; import { ScrollArea } from './ui/scroll-area'; import { Button } from './ui/button'; import { Badge } from './ui/badge'; @@ -9,6 +10,7 @@ import { cn } from '../lib/utils'; import ClaudeLogo from './ClaudeLogo'; import CursorLogo from './CursorLogo.jsx'; import TaskIndicator from './TaskIndicator'; +import ProjectCreationWizard from './ProjectCreationWizard'; import { api } from '../utils/api'; import { useTaskMaster } from '../contexts/TaskMasterContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext'; @@ -64,8 +66,6 @@ function Sidebar({ const [editingProject, setEditingProject] = useState(null); const [showNewProject, setShowNewProject] = useState(false); const [editingName, setEditingName] = useState(''); - const [newProjectPath, setNewProjectPath] = useState(''); - const [creatingProject, setCreatingProject] = useState(false); const [loadingSessions, setLoadingSessions] = useState({}); const [additionalSessions, setAdditionalSessions] = useState({}); const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set()); @@ -76,10 +76,6 @@ function Sidebar({ const [editingSessionName, setEditingSessionName] = useState(''); const [generatingSummary, setGeneratingSummary] = useState({}); const [searchFilter, setSearchFilter] = useState(''); - const [showPathDropdown, setShowPathDropdown] = useState(false); - const [pathList, setPathList] = useState([]); - const [filteredPaths, setFilteredPaths] = useState([]); - const [selectedPathIndex, setSelectedPathIndex] = useState(-1); // TaskMaster context const { setCurrentProject, mcpServerStatus } = useTaskMaster(); @@ -184,123 +180,6 @@ function Sidebar({ }; }, []); - // Load available paths for suggestions - useEffect(() => { - const loadPaths = async () => { - try { - // Get recent paths from localStorage - const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); - - // Load common/home directory paths - const response = await api.browseFilesystem(); - const data = await response.json(); - - if (data.suggestions) { - const homePaths = data.suggestions.map(s => ({ name: s.name, path: s.path })); - const allPaths = [...recentPaths.map(path => ({ name: path.split('/').pop(), path })), ...homePaths]; - setPathList(allPaths); - } else { - setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path }))); - } - } catch (error) { - console.error('Error loading paths:', error); - const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); - setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path }))); - } - }; - - loadPaths(); - }, []); - - // Handle input change and path filtering with dynamic browsing (ChatInterface pattern + dynamic browsing) - useEffect(() => { - const inputValue = newProjectPath.trim(); - - if (inputValue.length === 0) { - setShowPathDropdown(false); - return; - } - - // Show dropdown when user starts typing - setShowPathDropdown(true); - - const updateSuggestions = async () => { - // First show filtered existing suggestions from pathList - const staticFiltered = pathList.filter(pathItem => - pathItem.name.toLowerCase().includes(inputValue.toLowerCase()) || - pathItem.path.toLowerCase().includes(inputValue.toLowerCase()) - ); - - // Check if input looks like a directory path for dynamic browsing - const isDirPath = inputValue.includes('/') && inputValue.length > 1; - - if (isDirPath) { - try { - let dirToSearch; - - // Determine which directory to search - if (inputValue.endsWith('/')) { - // User typed "/home/simos/" - search inside /home/simos - dirToSearch = inputValue.slice(0, -1); - } else { - // User typed "/home/simos/con" - search inside /home/simos for items starting with "con" - const lastSlashIndex = inputValue.lastIndexOf('/'); - dirToSearch = inputValue.substring(0, lastSlashIndex); - } - - // Only search if we have a valid directory path (not root only) - if (dirToSearch && dirToSearch !== '') { - const response = await api.browseFilesystem(dirToSearch); - const data = await response.json(); - - if (data.suggestions) { - // Filter directories that match the current input - const partialName = inputValue.substring(inputValue.lastIndexOf('/') + 1); - const dynamicPaths = data.suggestions - .filter(suggestion => { - const dirName = suggestion.name; - return partialName ? dirName.toLowerCase().startsWith(partialName.toLowerCase()) : true; - }) - .map(s => ({ name: s.name, path: s.path })) - .slice(0, 8); - - // Combine static and dynamic suggestions, prioritize dynamic - const combined = [...dynamicPaths, ...staticFiltered].slice(0, 8); - setFilteredPaths(combined); - setSelectedPathIndex(-1); - return; - } - } - } catch (error) { - console.debug('Dynamic browsing failed:', error.message); - } - } - - // Fallback to just static filtered suggestions - setFilteredPaths(staticFiltered.slice(0, 8)); - setSelectedPathIndex(-1); - }; - - updateSuggestions(); - }, [newProjectPath, pathList]); - - // Select path from dropdown (ChatInterface pattern) - const selectPath = (pathItem) => { - setNewProjectPath(pathItem.path); - setShowPathDropdown(false); - setSelectedPathIndex(-1); - }; - - // Save path to recent paths - const saveToRecentPaths = (path) => { - try { - const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); - const updatedPaths = [path, ...recentPaths.filter(p => p !== path)].slice(0, 10); - localStorage.setItem('recentProjectPaths', JSON.stringify(updatedPaths)); - } catch (error) { - console.error('Error saving recent paths:', error); - } - }; const toggleProject = (projectName) => { const newExpanded = new Set(); @@ -465,50 +344,6 @@ function Sidebar({ } }; - const createNewProject = async () => { - if (!newProjectPath.trim()) { - alert('Please enter a project path'); - return; - } - - setCreatingProject(true); - - try { - const response = await api.createProject(newProjectPath.trim()); - - if (response.ok) { - const result = await response.json(); - - // Save the path to recent paths before clearing - saveToRecentPaths(newProjectPath.trim()); - - setShowNewProject(false); - setNewProjectPath(''); - setShowSuggestions(false); - - // Refresh projects to show the new one - if (window.refreshProjects) { - window.refreshProjects(); - } else { - window.location.reload(); - } - } else { - const error = await response.json(); - alert(error.error || 'Failed to create project. Please try again.'); - } - } catch (error) { - console.error('Error creating project:', error); - alert('Error creating project. Please try again.'); - } finally { - setCreatingProject(false); - } - }; - - const cancelNewProject = () => { - setShowNewProject(false); - setNewProjectPath(''); - setShowSuggestions(false); - }; const loadMoreSessions = async (project) => { // Check if we can load more sessions @@ -571,10 +406,27 @@ function Sidebar({ }; return ( -
+ <> + {/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */} + {showNewProject && ReactDOM.createPortal( + setShowNewProject(false)} + onProjectCreated={(project) => { + // Refresh projects list after creation + if (window.refreshProjects) { + window.refreshProjects(); + } else { + window.location.reload(); + } + }} + />, + document.body + )} + +
{/* Header */}
{/* Desktop Header */} @@ -648,263 +500,7 @@ function Sidebar({
- - {/* New Project Form */} - {showNewProject && ( -
- {/* Desktop Form */} -
-
- - Create New Project -
-
- setNewProjectPath(e.target.value)} - placeholder="/path/to/project or relative/path" - className="text-sm focus:ring-2 focus:ring-primary/20" - autoFocus - onKeyDown={(e) => { - // Handle path dropdown navigation (ChatInterface pattern) - if (showPathDropdown && filteredPaths.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedPathIndex(prev => - prev < filteredPaths.length - 1 ? prev + 1 : 0 - ); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedPathIndex(prev => - prev > 0 ? prev - 1 : filteredPaths.length - 1 - ); - } else if (e.key === 'Enter') { - e.preventDefault(); - if (selectedPathIndex >= 0) { - selectPath(filteredPaths[selectedPathIndex]); - } else if (filteredPaths.length > 0) { - selectPath(filteredPaths[0]); - } else { - createNewProject(); - } - return; - } else if (e.key === 'Escape') { - e.preventDefault(); - setShowPathDropdown(false); - return; - } else if (e.key === 'Tab') { - e.preventDefault(); - if (selectedPathIndex >= 0) { - selectPath(filteredPaths[selectedPathIndex]); - } else if (filteredPaths.length > 0) { - selectPath(filteredPaths[0]); - } - return; - } - } - - // Regular input handling - if (e.key === 'Enter') { - createNewProject(); - } - if (e.key === 'Escape') { - cancelNewProject(); - } - }} - /> - - {/* Path dropdown (ChatInterface pattern) */} - {showPathDropdown && filteredPaths.length > 0 && ( -
- {filteredPaths.map((pathItem, index) => ( -
{ - e.preventDefault(); - e.stopPropagation(); - }} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - selectPath(pathItem); - }} - > -
- -
-
{pathItem.name}
-
- {pathItem.path} -
-
-
-
- ))} -
- )} -
-
- - -
-
- - {/* Mobile Form - Simple Overlay */} -
-
-
-
-
- -
-
-

New Project

-
-
- -
- -
-
- setNewProjectPath(e.target.value)} - placeholder="/path/to/project or relative/path" - className="text-sm h-10 rounded-md focus:border-primary transition-colors" - autoFocus - onKeyDown={(e) => { - // Handle path dropdown navigation (same as desktop) - if (showPathDropdown && filteredPaths.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedPathIndex(prev => - prev < filteredPaths.length - 1 ? prev + 1 : 0 - ); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedPathIndex(prev => - prev > 0 ? prev - 1 : filteredPaths.length - 1 - ); - } else if (e.key === 'Enter') { - e.preventDefault(); - if (selectedPathIndex >= 0) { - selectPath(filteredPaths[selectedPathIndex]); - } else if (filteredPaths.length > 0) { - selectPath(filteredPaths[0]); - } else { - createNewProject(); - } - return; - } else if (e.key === 'Escape') { - e.preventDefault(); - setShowPathDropdown(false); - return; - } - } - - // Regular input handling - if (e.key === 'Enter') { - createNewProject(); - } - if (e.key === 'Escape') { - cancelNewProject(); - } - }} - style={{ - fontSize: '16px', // Prevents zoom on iOS - WebkitAppearance: 'none' - }} - /> - - {/* Mobile Path dropdown */} - {showPathDropdown && filteredPaths.length > 0 && ( -
- {filteredPaths.map((pathItem, index) => ( -
{ - e.preventDefault(); - e.stopPropagation(); - }} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - selectPath(pathItem); - }} - > -
- -
-
{pathItem.name}
-
- {pathItem.path} -
-
-
-
- ))} -
- )} -
- -
- - -
-
- - {/* Safe area for mobile */} -
-
-
-
- )} - {/* Search Filter and Actions */} {projects.length > 0 && !isLoading && (
@@ -1693,6 +1289,7 @@ function Sidebar({
+ ); } diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index 77acb6c..c227814 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -34,12 +34,14 @@ export const AuthProvider = ({ children }) => { const checkAuthStatus = async () => { try { + console.log('[AuthContext] Checking auth status...'); setIsLoading(true); setError(null); - + // Check if system needs setup const statusResponse = await api.auth.status(); const statusData = await statusResponse.json(); + console.log('[AuthContext] Status response:', statusData); if (statusData.needsSetup) { setNeedsSetup(true); @@ -70,9 +72,10 @@ export const AuthProvider = ({ children }) => { } } } catch (error) { - console.error('Auth status check failed:', error); + console.error('[AuthContext] Auth status check failed:', error); setError('Failed to check authentication status'); } finally { + console.log('[AuthContext] Auth check complete, isLoading=false'); setIsLoading(false); } }; diff --git a/src/index.css b/src/index.css index d47186a..90abc99 100644 --- a/src/index.css +++ b/src/index.css @@ -79,7 +79,7 @@ --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; + --input: 220 13% 46%; --ring: 217.2 91.2% 59.8%; } } diff --git a/src/utils/api.js b/src/utils/api.js index e3a93d3..9459417 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -71,6 +71,11 @@ export const api = { method: 'POST', body: JSON.stringify({ path }), }), + createWorkspace: (workspaceData) => + authenticatedFetch('/api/projects/create-workspace', { + method: 'POST', + body: JSON.stringify(workspaceData), + }), readFile: (projectName, filePath) => authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`), saveFile: (projectName, filePath, content) => From b416e542c7835c9d37f2266044b95a53b6bf5659 Mon Sep 17 00:00:00 2001 From: viper151 Date: Tue, 4 Nov 2025 09:39:37 +0100 Subject: [PATCH 2/3] Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- server/routes/projects.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/routes/projects.js b/server/routes/projects.js index 665f094..ff79671 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -101,7 +101,12 @@ router.post('/create-workspace', async (req, res) => { await cloneGitHubRepository(githubUrl, absolutePath, githubToken); } catch (error) { // Clean up created directory on failure - await fs.rm(absolutePath, { recursive: true, force: true }); + try { + await fs.rm(absolutePath, { recursive: true, force: true }); + } catch (cleanupError) { + console.error('Failed to clean up directory after clone failure:', cleanupError); + // Continue to throw original error + } throw new Error(`Failed to clone repository: ${error.message}`); } } From 255aed0b0198606021e2b4d26e11e6f58eed6b5c Mon Sep 17 00:00:00 2001 From: simos Date: Tue, 4 Nov 2025 09:29:21 +0000 Subject: [PATCH 3/3] feat(projects): add workspace path security validation and align github credentials implementation across components --- server/routes/projects.js | 168 ++++++++++++++++++++++- src/components/ApiKeysSettings.jsx | 17 +-- src/components/ProjectCreationWizard.jsx | 36 ++--- 3 files changed, 190 insertions(+), 31 deletions(-) diff --git a/server/routes/projects.js b/server/routes/projects.js index 665f094..0414faa 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -7,6 +7,147 @@ import { addProjectManually } from '../projects.js'; const router = express.Router(); +// Configure allowed workspace root (defaults to user's home directory) +const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir(); + +// System-critical paths that should never be used as workspace directories +const FORBIDDEN_PATHS = [ + '/', + '/etc', + '/bin', + '/sbin', + '/usr', + '/dev', + '/proc', + '/sys', + '/var', + '/boot', + '/root', + '/lib', + '/lib64', + '/opt', + '/tmp', + '/run' +]; + +/** + * Validates that a path is safe for workspace operations + * @param {string} requestedPath - The path to validate + * @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>} + */ +async function validateWorkspacePath(requestedPath) { + try { + // Resolve to absolute path + let absolutePath = path.resolve(requestedPath); + + // Check if path is a forbidden system directory + const normalizedPath = path.normalize(absolutePath); + if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') { + return { + valid: false, + error: 'Cannot use system-critical directories as workspace locations' + }; + } + + // Additional check for paths starting with forbidden directories + for (const forbidden of FORBIDDEN_PATHS) { + if (normalizedPath === forbidden || + normalizedPath.startsWith(forbidden + path.sep)) { + // Exception: /var/tmp and similar user-accessible paths might be allowed + // but /var itself and most /var subdirectories should be blocked + if (forbidden === '/var' && + (normalizedPath.startsWith('/var/tmp') || + normalizedPath.startsWith('/var/folders'))) { + continue; // Allow these specific cases + } + + return { + valid: false, + error: `Cannot create workspace in system directory: ${forbidden}` + }; + } + } + + // Try to resolve the real path (following symlinks) + let realPath; + try { + // Check if path exists to resolve real path + await fs.access(absolutePath); + realPath = await fs.realpath(absolutePath); + } catch (error) { + if (error.code === 'ENOENT') { + // Path doesn't exist yet - check parent directory + let parentPath = path.dirname(absolutePath); + try { + const parentRealPath = await fs.realpath(parentPath); + + // Reconstruct the full path with real parent + realPath = path.join(parentRealPath, path.basename(absolutePath)); + } catch (parentError) { + if (parentError.code === 'ENOENT') { + // Parent doesn't exist either - use the absolute path as-is + // We'll validate it's within allowed root + realPath = absolutePath; + } else { + throw parentError; + } + } + } else { + throw error; + } + } + + // Resolve the workspace root to its real path + const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT); + + // Ensure the resolved path is contained within the allowed workspace root + if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) && + realPath !== resolvedWorkspaceRoot) { + return { + valid: false, + error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}` + }; + } + + // Additional symlink check for existing paths + try { + await fs.access(absolutePath); + const stats = await fs.lstat(absolutePath); + + if (stats.isSymbolicLink()) { + // Verify symlink target is also within allowed root + const linkTarget = await fs.readlink(absolutePath); + const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget); + const realTarget = await fs.realpath(resolvedTarget); + + if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) && + realTarget !== resolvedWorkspaceRoot) { + return { + valid: false, + error: 'Symlink target is outside the allowed workspace root' + }; + } + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + // Path doesn't exist - that's fine for new workspace creation + } + + return { + valid: true, + resolvedPath: realPath + }; + + } catch (error) { + return { + valid: false, + error: `Path validation failed: ${error.message}` + }; + } +} + /** * Create a new workspace * POST /api/projects/create-workspace @@ -31,7 +172,16 @@ router.post('/create-workspace', async (req, res) => { return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' }); } - const absolutePath = path.resolve(workspacePath); + // Validate path safety before any operations + const validation = await validateWorkspacePath(workspacePath); + if (!validation.valid) { + return res.status(400).json({ + error: 'Invalid workspace path', + details: validation.error + }); + } + + const absolutePath = validation.resolvedPath; // Handle existing workspace if (workspaceType === 'existing') { @@ -134,12 +284,20 @@ async function getGithubTokenById(tokenId, userId) { const { getDatabase } = await import('../database/db.js'); const db = await getDatabase(); - const token = await db.get( - 'SELECT * FROM github_tokens WHERE id = ? AND user_id = ? AND is_active = 1', - [tokenId, userId] + const credential = await db.get( + 'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1', + [tokenId, userId, 'github_token'] ); - return token; + // Return in the expected format (github_token field for compatibility) + if (credential) { + return { + ...credential, + github_token: credential.credential_value + }; + } + + return null; } /** diff --git a/src/components/ApiKeysSettings.jsx b/src/components/ApiKeysSettings.jsx index 8a8c25b..c2d427c 100644 --- a/src/components/ApiKeysSettings.jsx +++ b/src/components/ApiKeysSettings.jsx @@ -33,11 +33,11 @@ function ApiKeysSettings() { setApiKeys(apiKeysData.apiKeys || []); // Fetch GitHub tokens - const githubRes = await fetch('/api/settings/github-tokens', { + const githubRes = await fetch('/api/settings/credentials?type=github_token', { headers: { 'Authorization': `Bearer ${token}` } }); const githubData = await githubRes.json(); - setGithubTokens(githubData.tokens || []); + setGithubTokens(githubData.credentials || []); } catch (error) { console.error('Error fetching settings:', error); } finally { @@ -108,15 +108,16 @@ function ApiKeysSettings() { try { const token = localStorage.getItem('auth-token'); - const res = await fetch('/api/settings/github-tokens', { + const res = await fetch('/api/settings/credentials', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ - tokenName: newTokenName, - githubToken: newGithubToken + credentialName: newTokenName, + credentialType: 'github_token', + credentialValue: newGithubToken }) }); @@ -137,7 +138,7 @@ function ApiKeysSettings() { try { const token = localStorage.getItem('auth-token'); - await fetch(`/api/settings/github-tokens/${tokenId}`, { + await fetch(`/api/settings/credentials/${tokenId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); @@ -150,7 +151,7 @@ function ApiKeysSettings() { const toggleGithubToken = async (tokenId, isActive) => { try { const token = localStorage.getItem('auth-token'); - await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, { + await fetch(`/api/settings/credentials/${tokenId}/toggle`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, @@ -349,7 +350,7 @@ function ApiKeysSettings() { className="flex items-center justify-between p-3 border rounded-lg" >
-
{token.token_name}
+
{token.credential_name}
Added: {new Date(token.created_at).toLocaleDateString()}
diff --git a/src/components/ProjectCreationWizard.jsx b/src/components/ProjectCreationWizard.jsx index c85d589..38564dc 100644 --- a/src/components/ProjectCreationWizard.jsx +++ b/src/components/ProjectCreationWizard.jsx @@ -13,7 +13,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const [workspacePath, setWorkspacePath] = useState(''); const [githubUrl, setGithubUrl] = useState(''); const [selectedGithubToken, setSelectedGithubToken] = useState(''); - const [useExistingToken, setUseExistingToken] = useState(true); + const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none' const [newGithubToken, setNewGithubToken] = useState(''); // UI state @@ -44,10 +44,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const loadGithubTokens = async () => { try { setLoadingTokens(true); - const response = await api.get('/settings/github-tokens'); + const response = await api.get('/settings/credentials?type=github_token'); const data = await response.json(); - const activeTokens = (data.tokens || []).filter(t => t.is_active); + const activeTokens = (data.credentials || []).filter(t => t.is_active); setAvailableTokens(activeTokens); // Auto-select first token if available @@ -122,9 +122,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { if (workspaceType === 'new' && githubUrl) { payload.githubUrl = githubUrl.trim(); - if (useExistingToken && selectedGithubToken) { + if (tokenMode === 'stored' && selectedGithubToken) { payload.githubTokenId = parseInt(selectedGithubToken); - } else if (!useExistingToken && newGithubToken) { + } else if (tokenMode === 'new' && newGithubToken) { payload.newGithubToken = newGithubToken.trim(); } } @@ -364,9 +364,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { {/* Token Selection Tabs */}
- {useExistingToken ? ( + {tokenMode === 'stored' ? (
- ) : ( + ) : tokenMode === 'new' ? (
- )} + ) : null} ) : (
@@ -498,9 +498,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
Authentication: - {useExistingToken && selectedGithubToken - ? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.token_name || 'Unknown'}` - : newGithubToken + {tokenMode === 'stored' && selectedGithubToken + ? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}` + : tokenMode === 'new' && newGithubToken ? 'Using provided token' : 'No authentication'}