mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-10 14:09:39 +00:00
Update package version to 1.1.3, add new dependencies for authentication and database management, and implement user authentication features including registration and login. Enhance API routes for protected access and integrate WebSocket authentication.
This commit is contained in:
1683
package-lock.json
generated
1683
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,11 +33,13 @@
|
||||
"@uiw/react-codemirror": "^4.23.13",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
@@ -46,6 +48,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"ws": "^8.14.2",
|
||||
"xterm": "^5.3.0",
|
||||
|
||||
100
server/database/db.js
Normal file
100
server/database/db.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'auth.db');
|
||||
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
|
||||
|
||||
// Create database connection
|
||||
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||
if (err) {
|
||||
console.error('Error opening database:', err.message);
|
||||
} else {
|
||||
console.log('Connected to SQLite database');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize database with schema
|
||||
const initializeDatabase = async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
|
||||
db.exec(initSQL, (err) => {
|
||||
if (err) {
|
||||
console.error('Error initializing database:', err.message);
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('Database initialized successfully');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading init SQL file:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// User database operations
|
||||
const userDb = {
|
||||
// Check if any users exist
|
||||
hasUsers: () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get('SELECT COUNT(*) as count FROM users', (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row.count > 0);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Create a new user
|
||||
createUser: (username, passwordHash) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
||||
stmt.run(username, passwordHash, function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ id: this.lastID, username });
|
||||
}
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
},
|
||||
|
||||
// Get user by username
|
||||
getUserByUsername: (username) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get('SELECT * FROM users WHERE username = ? AND is_active = 1', [username], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Update last login time
|
||||
updateLastLogin: (userId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [userId], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Get user by ID
|
||||
getUserById: (userId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1', [userId], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
initializeDatabase,
|
||||
userDb
|
||||
};
|
||||
16
server/database/init.sql
Normal file
16
server/database/init.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Initialize authentication database
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Users table (single user system)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
||||
@@ -33,6 +33,9 @@ const fetch = require('node-fetch');
|
||||
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } = require('./projects');
|
||||
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
|
||||
const gitRoutes = require('./routes/git');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const { initializeDatabase } = require('./database/db');
|
||||
const { validateApiKey, authenticateToken, authenticateWebSocket } = require('./middleware/auth');
|
||||
|
||||
// File system watcher for projects folder
|
||||
let projectsWatcher = null;
|
||||
@@ -142,19 +145,43 @@ const wss = new WebSocketServer({
|
||||
server,
|
||||
verifyClient: (info) => {
|
||||
console.log('WebSocket connection attempt to:', info.req.url);
|
||||
return true; // Accept all connections for now
|
||||
|
||||
// Extract token from query parameters or headers
|
||||
const url = new URL(info.req.url, 'http://localhost');
|
||||
const token = url.searchParams.get('token') ||
|
||||
info.req.headers.authorization?.split(' ')[1];
|
||||
|
||||
// Verify token
|
||||
const user = authenticateWebSocket(token);
|
||||
if (!user) {
|
||||
console.log('❌ WebSocket authentication failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store user info in the request for later use
|
||||
info.req.user = user;
|
||||
console.log('✅ WebSocket authenticated for user:', user.username);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Optional API key validation (if configured)
|
||||
app.use('/api', validateApiKey);
|
||||
|
||||
// Authentication routes (public)
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Git API Routes (protected)
|
||||
app.use('/api/git', authenticateToken, gitRoutes);
|
||||
|
||||
// Static files served after API routes
|
||||
app.use(express.static(path.join(__dirname, '../dist')));
|
||||
|
||||
// Git API Routes
|
||||
app.use('/api/git', gitRoutes);
|
||||
|
||||
// API Routes
|
||||
app.get('/api/config', (req, res) => {
|
||||
// API Routes (protected)
|
||||
app.get('/api/config', authenticateToken, (req, res) => {
|
||||
// Always use the server's actual IP and port for WebSocket connections
|
||||
const serverIP = getServerIP();
|
||||
const host = `${serverIP}:${PORT}`;
|
||||
@@ -168,7 +195,7 @@ app.get('/api/config', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/projects', async (req, res) => {
|
||||
app.get('/api/projects', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const projects = await getProjects();
|
||||
res.json(projects);
|
||||
@@ -177,7 +204,7 @@ app.get('/api/projects', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectName/sessions', async (req, res) => {
|
||||
app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { limit = 5, offset = 0 } = req.query;
|
||||
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
|
||||
@@ -188,7 +215,7 @@ app.get('/api/projects/:projectName/sessions', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get messages for a specific session
|
||||
app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, res) => {
|
||||
app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
const messages = await getSessionMessages(projectName, sessionId);
|
||||
@@ -199,7 +226,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, r
|
||||
});
|
||||
|
||||
// Rename project endpoint
|
||||
app.put('/api/projects/:projectName/rename', async (req, res) => {
|
||||
app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { displayName } = req.body;
|
||||
await renameProject(req.params.projectName, displayName);
|
||||
@@ -210,7 +237,7 @@ app.put('/api/projects/:projectName/rename', async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete session endpoint
|
||||
app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) => {
|
||||
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
await deleteSession(projectName, sessionId);
|
||||
@@ -221,7 +248,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) =>
|
||||
});
|
||||
|
||||
// Delete project endpoint (only if empty)
|
||||
app.delete('/api/projects/:projectName', async (req, res) => {
|
||||
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
await deleteProject(projectName);
|
||||
@@ -232,7 +259,7 @@ app.delete('/api/projects/:projectName', async (req, res) => {
|
||||
});
|
||||
|
||||
// Create project endpoint
|
||||
app.post('/api/projects/create', async (req, res) => {
|
||||
app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { path: projectPath } = req.body;
|
||||
|
||||
@@ -249,7 +276,7 @@ app.post('/api/projects/create', async (req, res) => {
|
||||
});
|
||||
|
||||
// Read file content endpoint
|
||||
app.get('/api/projects/:projectName/file', async (req, res) => {
|
||||
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { filePath } = req.query;
|
||||
@@ -278,7 +305,7 @@ app.get('/api/projects/:projectName/file', async (req, res) => {
|
||||
});
|
||||
|
||||
// Serve binary file content endpoint (for images, etc.)
|
||||
app.get('/api/projects/:projectName/files/content', async (req, res) => {
|
||||
app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { path: filePath } = req.query;
|
||||
@@ -324,7 +351,7 @@ app.get('/api/projects/:projectName/files/content', async (req, res) => {
|
||||
});
|
||||
|
||||
// Save file content endpoint
|
||||
app.put('/api/projects/:projectName/file', async (req, res) => {
|
||||
app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { filePath, content } = req.body;
|
||||
@@ -371,7 +398,7 @@ app.put('/api/projects/:projectName/file', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectName/files', async (req, res) => {
|
||||
app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
|
||||
const fs = require('fs').promises;
|
||||
@@ -409,12 +436,16 @@ wss.on('connection', (ws, request) => {
|
||||
const url = request.url;
|
||||
console.log('🔗 Client connected to:', url);
|
||||
|
||||
if (url === '/shell') {
|
||||
// Parse URL to get pathname without query parameters
|
||||
const urlObj = new URL(url, 'http://localhost');
|
||||
const pathname = urlObj.pathname;
|
||||
|
||||
if (pathname === '/shell') {
|
||||
handleShellConnection(ws);
|
||||
} else if (url === '/ws') {
|
||||
} else if (pathname === '/ws') {
|
||||
handleChatConnection(ws);
|
||||
} else {
|
||||
console.log('❌ Unknown WebSocket path:', url);
|
||||
console.log('❌ Unknown WebSocket path:', pathname);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
@@ -629,7 +660,7 @@ function handleShellConnection(ws) {
|
||||
});
|
||||
}
|
||||
// Audio transcription endpoint
|
||||
app.post('/api/transcribe', async (req, res) => {
|
||||
app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const multer = require('multer');
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
@@ -835,9 +866,24 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Initialize database and start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Initialize authentication database
|
||||
await initializeDatabase();
|
||||
console.log('✅ Database initialized successfully');
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
|
||||
|
||||
// Start watching the projects folder for changes
|
||||
setupProjectsWatcher();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
80
server/middleware/auth.js
Normal file
80
server/middleware/auth.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { userDb } = require('../database/db');
|
||||
|
||||
// Get JWT secret from environment or use default (for development)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
|
||||
|
||||
// Optional API key middleware
|
||||
const validateApiKey = (req, res, next) => {
|
||||
// Skip API key validation if not configured
|
||||
if (!process.env.API_KEY) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey !== process.env.API_KEY) {
|
||||
return res.status(401).json({ error: 'Invalid API key' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// JWT authentication middleware
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access denied. No token provided.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// Verify user still exists and is active
|
||||
const user = await userDb.getUserById(decoded.userId);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return res.status(403).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
// Generate JWT token (never expires)
|
||||
const generateToken = (user) => {
|
||||
return jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username
|
||||
},
|
||||
JWT_SECRET
|
||||
// No expiration - token lasts forever
|
||||
);
|
||||
};
|
||||
|
||||
// WebSocket authentication function
|
||||
const authenticateWebSocket = (token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
console.error('WebSocket token verification error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
validateApiKey,
|
||||
authenticateToken,
|
||||
generateToken,
|
||||
authenticateWebSocket,
|
||||
JWT_SECRET
|
||||
};
|
||||
125
server/routes/auth.js
Normal file
125
server/routes/auth.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { userDb } = require('../database/db');
|
||||
const { generateToken, authenticateToken } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Check auth status and setup requirements
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
const hasUsers = await userDb.hasUsers();
|
||||
res.json({
|
||||
needsSetup: !hasUsers,
|
||||
isAuthenticated: false // Will be overridden by frontend if token exists
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auth status error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// User registration (setup) - only allowed if no users exist
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
if (username.length < 3 || password.length < 6) {
|
||||
return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
|
||||
}
|
||||
|
||||
// Check if users already exist (only allow one user)
|
||||
const hasUsers = await userDb.hasUsers();
|
||||
if (hasUsers) {
|
||||
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Create user
|
||||
const user = await userDb.createUser(username, passwordHash);
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
await userDb.updateLastLogin(user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
token
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
res.status(409).json({ error: 'Username already exists' });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// User login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await userDb.getUserByUsername(username);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
await userDb.updateLastLogin(user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
token
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user (protected route)
|
||||
router.get('/user', authenticateToken, (req, res) => {
|
||||
res.json({
|
||||
user: req.user
|
||||
});
|
||||
});
|
||||
|
||||
// Logout (client-side token removal, but this endpoint can be used for logging)
|
||||
router.post('/logout', authenticateToken, (req, res) => {
|
||||
// In a simple JWT system, logout is mainly client-side
|
||||
// This endpoint exists for consistency and potential future logging
|
||||
res.json({ success: true, message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
11
src/App.jsx
11
src/App.jsx
@@ -28,7 +28,10 @@ import QuickSettingsPanel from './components/QuickSettingsPanel';
|
||||
|
||||
import { useWebSocket } from './utils/websocket';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||
import { api } from './utils/api';
|
||||
|
||||
|
||||
// Main App component with routing
|
||||
@@ -182,7 +185,7 @@ function AppContent() {
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
setIsLoadingProjects(true);
|
||||
const response = await fetch('/api/projects');
|
||||
const response = await api.projects();
|
||||
const data = await response.json();
|
||||
|
||||
// Optimize to preserve object references when data hasn't changed
|
||||
@@ -304,7 +307,7 @@ function AppContent() {
|
||||
const handleSidebarRefresh = async () => {
|
||||
// Refresh only the sessions for all projects, don't change selected state
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
const response = await api.projects();
|
||||
const freshProjects = await response.json();
|
||||
|
||||
// Optimize to preserve object references and minimize re-renders
|
||||
@@ -633,12 +636,16 @@ function AppContent() {
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ProtectedRoute>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import ClaudeLogo from './ClaudeLogo.jsx';
|
||||
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
// Memoized message component to prevent unnecessary re-renders
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
||||
@@ -949,7 +950,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
setIsLoadingSessionMessages(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`);
|
||||
const response = await api.sessionMessages(projectName, sessionId);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load session messages');
|
||||
}
|
||||
@@ -1451,7 +1452,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
const fetchProjectFiles = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${selectedProject.name}/files`);
|
||||
const response = await api.getFiles(selectedProject.name);
|
||||
if (response.ok) {
|
||||
const files = await response.json();
|
||||
// Flatten the file tree to get all file paths
|
||||
|
||||
@@ -10,6 +10,7 @@ import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, Decoration } from '@codemirror/view';
|
||||
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state';
|
||||
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function CodeEditor({ file, onClose, projectPath }) {
|
||||
const [content, setContent] = useState('');
|
||||
@@ -139,7 +140,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch(`/api/projects/${file.projectName}/file?filePath=${encodeURIComponent(file.path)}`);
|
||||
const response = await api.readFile(file.projectName, file.path);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
||||
@@ -176,16 +177,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${file.projectName}/file`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filePath: file.path,
|
||||
content: content
|
||||
})
|
||||
});
|
||||
const response = await api.saveFile(file.projectName, file.path, content);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Folder, FolderOpen, File, FileText, FileCode } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import ImageViewer from './ImageViewer';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function FileTree({ selectedProject }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
@@ -22,7 +23,7 @@ function FileTree({ selectedProject }) {
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${selectedProject.name}/files`);
|
||||
const response = await api.getFiles(selectedProject.name);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles } from 'lucide-react';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
function GitPanel({ selectedProject, isMobile }) {
|
||||
const [gitStatus, setGitStatus] = useState(null);
|
||||
@@ -55,7 +56,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const response = await authenticatedFetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Git status response:', data);
|
||||
@@ -93,7 +94,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const fetchBranches = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const response = await authenticatedFetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.branches) {
|
||||
@@ -106,7 +107,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const switchBranch = async (branchName) => {
|
||||
try {
|
||||
const response = await fetch('/api/git/checkout', {
|
||||
const response = await authenticatedFetch('/api/git/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -133,7 +134,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
setIsCreatingBranch(true);
|
||||
try {
|
||||
const response = await fetch('/api/git/create-branch', {
|
||||
const response = await authenticatedFetch('/api/git/create-branch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -162,7 +163,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const fetchFileDiff = async (filePath) => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
||||
const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.diff) {
|
||||
@@ -178,7 +179,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const fetchRecentCommits = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
|
||||
const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.commits) {
|
||||
@@ -191,7 +192,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const fetchCommitDiff = async (commitHash) => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`);
|
||||
const response = await authenticatedFetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.diff) {
|
||||
@@ -208,7 +209,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
const generateCommitMessage = async () => {
|
||||
setIsGeneratingMessage(true);
|
||||
try {
|
||||
const response = await fetch('/api/git/generate-commit-message', {
|
||||
const response = await authenticatedFetch('/api/git/generate-commit-message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -275,7 +276,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
setIsCommitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/git/commit', {
|
||||
const response = await authenticatedFetch('/api/git/commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
108
src/components/LoginForm.jsx
Normal file
108
src/components/LoginForm.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
|
||||
const LoginForm = () => {
|
||||
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('Please enter both username and password');
|
||||
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">
|
||||
<ClaudeLogo size={64} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Sign in to your Claude Code UI account
|
||||
</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">
|
||||
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>
|
||||
|
||||
{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 ? 'Signing in...' : 'Sign In'}
|
||||
</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;
|
||||
42
src/components/ProtectedRoute.jsx
Normal file
42
src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SetupForm from './SetupForm';
|
||||
import LoginForm from './LoginForm';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
|
||||
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">
|
||||
<ClaudeLogo size={64} />
|
||||
</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 } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return <SetupForm />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
135
src/components/SetupForm.jsx
Normal file
135
src/components/SetupForm.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
|
||||
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">
|
||||
<ClaudeLogo size={64} />
|
||||
</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;
|
||||
@@ -322,10 +322,21 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
if (isConnecting || isConnected) return;
|
||||
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.error('No authentication token found for Shell WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch server configuration to get the correct WebSocket URL
|
||||
let wsBaseUrl;
|
||||
try {
|
||||
const configResponse = await fetch('/api/config');
|
||||
const configResponse = await fetch('/api/config', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const config = await configResponse.json();
|
||||
wsBaseUrl = config.wsUrl;
|
||||
|
||||
@@ -343,7 +354,8 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
|
||||
}
|
||||
|
||||
const wsUrl = `${wsBaseUrl}/shell`;
|
||||
// Include token in WebSocket URL as query parameter
|
||||
const wsUrl = `${wsBaseUrl}/shell?token=${encodeURIComponent(token)}`;
|
||||
|
||||
ws.current = new WebSocket(wsUrl);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from './ui/input';
|
||||
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
// Move formatTimeAgo outside component to avoid recreation on every render
|
||||
const formatTimeAgo = (dateString, currentTime) => {
|
||||
@@ -132,13 +133,7 @@ function Sidebar({
|
||||
|
||||
const saveProjectName = async (projectName) => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectName}/rename`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ displayName: editingName }),
|
||||
});
|
||||
const response = await api.renameProject(projectName, editingName);
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh projects to get updated data
|
||||
@@ -164,9 +159,7 @@ function Sidebar({
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const response = await api.deleteSession(projectName, sessionId);
|
||||
|
||||
if (response.ok) {
|
||||
// Call parent callback if provided
|
||||
@@ -189,9 +182,7 @@ function Sidebar({
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectName}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const response = await api.deleteProject(projectName);
|
||||
|
||||
if (response.ok) {
|
||||
// Call parent callback if provided
|
||||
@@ -218,15 +209,7 @@ function Sidebar({
|
||||
setCreatingProject(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/projects/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: newProjectPath.trim()
|
||||
}),
|
||||
});
|
||||
const response = await api.createProject(newProjectPath.trim());
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
158
src/contexts/AuthContext.jsx
Normal file
158
src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
const AuthContext = createContext({
|
||||
user: null,
|
||||
token: null,
|
||||
login: () => {},
|
||||
register: () => {},
|
||||
logout: () => {},
|
||||
isLoading: true,
|
||||
needsSetup: false,
|
||||
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 [error, setError] = useState(null);
|
||||
|
||||
// Check authentication status on mount
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
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);
|
||||
} 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('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,
|
||||
error
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
81
src/utils/api.js
Normal file
81
src/utils/api.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Utility function for authenticated API calls
|
||||
export const authenticatedFetch = (url, options = {}) => {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// API endpoints
|
||||
export const api = {
|
||||
// Auth endpoints (no token required)
|
||||
auth: {
|
||||
status: () => fetch('/api/auth/status'),
|
||||
login: (username, password) => fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
register: (username, password) => fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
user: () => authenticatedFetch('/api/auth/user'),
|
||||
logout: () => authenticatedFetch('/api/auth/logout', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Protected endpoints
|
||||
config: () => authenticatedFetch('/api/config'),
|
||||
projects: () => authenticatedFetch('/api/projects'),
|
||||
sessions: (projectName, limit = 5, offset = 0) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
|
||||
sessionMessages: (projectName, sessionId) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`),
|
||||
renameProject: (projectName, displayName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/rename`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ displayName }),
|
||||
}),
|
||||
deleteSession: (projectName, sessionId) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteProject: (projectName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
createProject: (path) =>
|
||||
authenticatedFetch('/api/projects/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
readFile: (projectName, filePath) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
|
||||
saveFile: (projectName, filePath, content) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/file`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ filePath, content }),
|
||||
}),
|
||||
getFiles: (projectName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/files`),
|
||||
transcribe: (formData) =>
|
||||
authenticatedFetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {}, // Let browser set Content-Type for FormData
|
||||
}),
|
||||
};
|
||||
@@ -21,10 +21,21 @@ export function useWebSocket() {
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.warn('No authentication token found for WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch server configuration to get the correct WebSocket URL
|
||||
let wsBaseUrl;
|
||||
try {
|
||||
const configResponse = await fetch('/api/config');
|
||||
const configResponse = await fetch('/api/config', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const config = await configResponse.json();
|
||||
wsBaseUrl = config.wsUrl;
|
||||
|
||||
@@ -44,7 +55,8 @@ export function useWebSocket() {
|
||||
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
|
||||
}
|
||||
|
||||
const wsUrl = `${wsBaseUrl}/ws`;
|
||||
// Include token in WebSocket URL as query parameter
|
||||
const wsUrl = `${wsBaseUrl}/ws?token=${encodeURIComponent(token)}`;
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { api } from './api';
|
||||
|
||||
export async function transcribeWithWhisper(audioBlob, onStatusChange) {
|
||||
const formData = new FormData();
|
||||
const fileName = `recording_${Date.now()}.webm`;
|
||||
@@ -14,10 +16,7 @@ export async function transcribeWithWhisper(audioBlob, onStatusChange) {
|
||||
onStatusChange('transcribing');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const response = await api.transcribe(formData);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
Reference in New Issue
Block a user