mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
adding more security to api endpoints to require api token for all access, no by passing
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@automaker/utils": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
@@ -37,6 +38,7 @@
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/morgan": "^1.9.10",
|
||||
|
||||
@@ -9,15 +9,22 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import morgan from 'morgan';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
||||
import { initAllowedPaths } from '@automaker/platform';
|
||||
import { authMiddleware, getAuthStatus } from './lib/auth.js';
|
||||
import {
|
||||
authMiddleware,
|
||||
validateSession,
|
||||
validateApiKey,
|
||||
getSessionCookieName,
|
||||
} from './lib/auth.js';
|
||||
import { createAuthRoutes } from './routes/auth/index.js';
|
||||
import { createFsRoutes } from './routes/fs/index.js';
|
||||
import { createHealthRoutes } from './routes/health/index.js';
|
||||
import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
|
||||
import { createAgentRoutes } from './routes/agent/index.js';
|
||||
import { createSessionsRoutes } from './routes/sessions/index.js';
|
||||
import { createFeaturesRoutes } from './routes/features/index.js';
|
||||
@@ -105,17 +112,43 @@ if (ENABLE_REQUEST_LOGGING) {
|
||||
})
|
||||
);
|
||||
}
|
||||
// SECURITY: Restrict CORS to localhost UI origins to prevent drive-by attacks
|
||||
// from malicious websites. MCP server endpoints can execute arbitrary commands,
|
||||
// so allowing any origin would enable RCE from any website visited while Automaker runs.
|
||||
const DEFAULT_CORS_ORIGINS = ['http://localhost:3007', 'http://127.0.0.1:3007'];
|
||||
// CORS configuration
|
||||
// When using credentials (cookies), origin cannot be '*'
|
||||
// We dynamically allow the requesting origin for local development
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS,
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (like mobile apps, curl, Electron)
|
||||
if (!origin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If CORS_ORIGIN is set, use it (can be comma-separated list)
|
||||
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
|
||||
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, origin);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For local development, allow localhost origins
|
||||
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject other origins by default for security
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Create shared event emitter for streaming
|
||||
const events: EventEmitter = createEventEmitter();
|
||||
@@ -144,12 +177,16 @@ setInterval(() => {
|
||||
}
|
||||
}, VALIDATION_CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Mount API routes - health is unauthenticated for monitoring
|
||||
// Mount API routes - health and auth are unauthenticated
|
||||
app.use('/api/health', createHealthRoutes());
|
||||
app.use('/api/auth', createAuthRoutes());
|
||||
|
||||
// Apply authentication to all other routes
|
||||
app.use('/api', authMiddleware);
|
||||
|
||||
// Protected health endpoint with detailed info
|
||||
app.get('/api/health/detailed', createDetailedHandler());
|
||||
|
||||
app.use('/api/fs', createFsRoutes(events));
|
||||
app.use('/api/agent', createAgentRoutes(agentService, events));
|
||||
app.use('/api/sessions', createSessionsRoutes(agentService));
|
||||
@@ -182,10 +219,70 @@ const wss = new WebSocketServer({ noServer: true });
|
||||
const terminalWss = new WebSocketServer({ noServer: true });
|
||||
const terminalService = getTerminalService();
|
||||
|
||||
/**
|
||||
* Authenticate WebSocket upgrade requests
|
||||
* Checks for API key in header/query, session token in header/query, OR valid session cookie
|
||||
*/
|
||||
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
|
||||
// Check for API key in header (Electron mode)
|
||||
const headerKey = request.headers['x-api-key'] as string | undefined;
|
||||
if (headerKey && validateApiKey(headerKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for session token in header (web mode with explicit token)
|
||||
const sessionTokenHeader = request.headers['x-session-token'] as string | undefined;
|
||||
if (sessionTokenHeader && validateSession(sessionTokenHeader)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for API key in query param (fallback for WebSocket)
|
||||
const queryKey = url.searchParams.get('apiKey');
|
||||
if (queryKey && validateApiKey(queryKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for session token in query param (fallback for WebSocket in web mode)
|
||||
const queryToken = url.searchParams.get('sessionToken');
|
||||
if (queryToken && validateSession(queryToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for session cookie (web mode)
|
||||
const cookieHeader = request.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const cookieName = getSessionCookieName();
|
||||
const cookies = cookieHeader.split(';').reduce(
|
||||
(acc, cookie) => {
|
||||
const [key, value] = cookie.trim().split('=');
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
const sessionToken = cookies[cookieName];
|
||||
if (sessionToken && validateSession(sessionToken)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle HTTP upgrade requests manually to route to correct WebSocket server
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
|
||||
// Authenticate all WebSocket connections
|
||||
if (!authenticateWebSocket(request)) {
|
||||
console.log('[WebSocket] Authentication failed, rejecting connection');
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/events') {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, request);
|
||||
|
||||
@@ -1,39 +1,224 @@
|
||||
/**
|
||||
* Authentication middleware for API security
|
||||
*
|
||||
* Supports API key authentication via header or environment variable.
|
||||
* Supports two authentication methods:
|
||||
* 1. Header-based (X-API-Key) - Used by Electron mode
|
||||
* 2. Cookie-based (HTTP-only session cookie) - Used by web mode
|
||||
*
|
||||
* Auto-generates an API key on first run if none is configured.
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// API key from environment (optional - if not set, auth is disabled)
|
||||
const API_KEY = process.env.AUTOMAKER_API_KEY;
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, '.sessions');
|
||||
const SESSION_COOKIE_NAME = 'automaker_session';
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
// Session store - persisted to file for survival across server restarts
|
||||
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
|
||||
|
||||
/**
|
||||
* Load sessions from file on startup
|
||||
*/
|
||||
function loadSessions(): void {
|
||||
try {
|
||||
if (fs.existsSync(SESSIONS_FILE)) {
|
||||
const data = fs.readFileSync(SESSIONS_FILE, 'utf-8');
|
||||
const sessions = JSON.parse(data) as Array<
|
||||
[string, { createdAt: number; expiresAt: number }]
|
||||
>;
|
||||
const now = Date.now();
|
||||
let loadedCount = 0;
|
||||
let expiredCount = 0;
|
||||
|
||||
for (const [token, session] of sessions) {
|
||||
// Only load non-expired sessions
|
||||
if (session.expiresAt > now) {
|
||||
validSessions.set(token, session);
|
||||
loadedCount++;
|
||||
} else {
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedCount > 0 || expiredCount > 0) {
|
||||
console.log(`[Auth] Loaded ${loadedCount} sessions (${expiredCount} expired)`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Error loading sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sessions to file
|
||||
*/
|
||||
function saveSessions(): void {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||
const sessions = Array.from(validSessions.entries());
|
||||
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions), { encoding: 'utf-8', mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing sessions on startup
|
||||
loadSessions();
|
||||
|
||||
/**
|
||||
* Ensure an API key exists - either from env var, file, or generate new one.
|
||||
* This provides CSRF protection by requiring a secret key for all API requests.
|
||||
*/
|
||||
function ensureApiKey(): string {
|
||||
// First check environment variable (Electron passes it this way)
|
||||
if (process.env.AUTOMAKER_API_KEY) {
|
||||
console.log('[Auth] Using API key from environment variable');
|
||||
return process.env.AUTOMAKER_API_KEY;
|
||||
}
|
||||
|
||||
// Try to read from file
|
||||
try {
|
||||
if (fs.existsSync(API_KEY_FILE)) {
|
||||
const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim();
|
||||
if (key) {
|
||||
console.log('[Auth] Loaded API key from file');
|
||||
return key;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Error reading API key file:', error);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
const newKey = crypto.randomUUID();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
|
||||
fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log('[Auth] Generated new API key');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save API key:', error);
|
||||
}
|
||||
return newKey;
|
||||
}
|
||||
|
||||
// API key - always generated/loaded on startup for CSRF protection
|
||||
const API_KEY = ensureApiKey();
|
||||
|
||||
// Print API key to console for web mode users
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ 🔐 API Key for Web Mode Authentication ║
|
||||
╠═══════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ When accessing via browser, you'll be prompted to enter this key: ║
|
||||
║ ║
|
||||
║ ${API_KEY}
|
||||
║ ║
|
||||
║ In Electron mode, authentication is handled automatically. ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure session token
|
||||
*/
|
||||
function generateSessionToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session and return the token
|
||||
*/
|
||||
export function createSession(): string {
|
||||
const token = generateSessionToken();
|
||||
const now = Date.now();
|
||||
validSessions.set(token, {
|
||||
createdAt: now,
|
||||
expiresAt: now + SESSION_MAX_AGE_MS,
|
||||
});
|
||||
saveSessions(); // Persist to file
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session token
|
||||
*/
|
||||
export function validateSession(token: string): boolean {
|
||||
const session = validSessions.get(token);
|
||||
if (!session) return false;
|
||||
|
||||
if (Date.now() > session.expiresAt) {
|
||||
validSessions.delete(token);
|
||||
saveSessions(); // Persist removal
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a session token
|
||||
*/
|
||||
export function invalidateSession(token: string): void {
|
||||
validSessions.delete(token);
|
||||
saveSessions(); // Persist removal
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the API key
|
||||
*/
|
||||
export function validateApiKey(key: string): boolean {
|
||||
return key === API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session cookie options
|
||||
*/
|
||||
export function getSessionCookieOptions(): {
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: 'strict' | 'lax' | 'none';
|
||||
maxAge: number;
|
||||
path: string;
|
||||
} {
|
||||
return {
|
||||
httpOnly: true, // JavaScript cannot access this cookie
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
||||
sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
|
||||
maxAge: SESSION_MAX_AGE_MS,
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session cookie name
|
||||
*/
|
||||
export function getSessionCookieName(): string {
|
||||
return SESSION_COOKIE_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
*
|
||||
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
|
||||
* If not set, allows all requests (development mode).
|
||||
* Accepts either:
|
||||
* 1. X-API-Key header (for Electron mode)
|
||||
* 2. X-Session-Token header (for web mode with explicit token)
|
||||
* 3. apiKey query parameter (fallback for cases where headers can't be set)
|
||||
* 4. Session cookie (for web mode)
|
||||
*/
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
// If no API key is configured, allow all requests
|
||||
if (!API_KEY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for API key in header
|
||||
const providedKey = req.headers['x-api-key'] as string | undefined;
|
||||
|
||||
if (!providedKey) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required. Provide X-API-Key header.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providedKey !== API_KEY) {
|
||||
// Check for API key in header (Electron mode)
|
||||
const headerKey = req.headers['x-api-key'] as string | undefined;
|
||||
if (headerKey) {
|
||||
if (headerKey === API_KEY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
@@ -41,14 +226,53 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
// Check for session token in header (web mode with explicit token)
|
||||
const sessionTokenHeader = req.headers['x-session-token'] as string | undefined;
|
||||
if (sessionTokenHeader) {
|
||||
if (validateSession(sessionTokenHeader)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired session token.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for API key in query parameter (fallback)
|
||||
const queryKey = req.query.apiKey as string | undefined;
|
||||
if (queryKey) {
|
||||
if (queryKey === API_KEY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for session cookie (web mode)
|
||||
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME] as string | undefined;
|
||||
if (sessionToken && validateSession(sessionToken)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// No valid authentication
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authentication is enabled
|
||||
* Check if authentication is enabled (always true now)
|
||||
*/
|
||||
export function isAuthEnabled(): boolean {
|
||||
return !!API_KEY;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +280,38 @@ export function isAuthEnabled(): boolean {
|
||||
*/
|
||||
export function getAuthStatus(): { enabled: boolean; method: string } {
|
||||
return {
|
||||
enabled: !!API_KEY,
|
||||
method: API_KEY ? 'api_key' : 'none',
|
||||
enabled: true,
|
||||
method: 'api_key_or_session',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is authenticated (for status endpoint)
|
||||
*/
|
||||
export function isRequestAuthenticated(req: Request): boolean {
|
||||
// Check API key header
|
||||
const headerKey = req.headers['x-api-key'] as string | undefined;
|
||||
if (headerKey && headerKey === API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check session token header
|
||||
const sessionTokenHeader = req.headers['x-session-token'] as string | undefined;
|
||||
if (sessionTokenHeader && validateSession(sessionTokenHeader)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check query parameter
|
||||
const queryKey = req.query.apiKey as string | undefined;
|
||||
if (queryKey && queryKey === API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check cookie
|
||||
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME] as string | undefined;
|
||||
if (sessionToken && validateSession(sessionToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
150
apps/server/src/routes/auth/index.ts
Normal file
150
apps/server/src/routes/auth/index.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Auth routes - Login, logout, and status endpoints
|
||||
*
|
||||
* Security model:
|
||||
* - Web mode: User enters API key (shown on server console) to get HTTP-only session cookie
|
||||
* - Electron mode: Uses X-API-Key header (handled automatically via IPC)
|
||||
*
|
||||
* The session cookie is:
|
||||
* - HTTP-only: JavaScript cannot read it (protects against XSS)
|
||||
* - SameSite=Strict: Only sent for same-site requests (protects against CSRF)
|
||||
*
|
||||
* Mounted at /api/auth in the main server (BEFORE auth middleware).
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
validateApiKey,
|
||||
createSession,
|
||||
invalidateSession,
|
||||
getSessionCookieOptions,
|
||||
getSessionCookieName,
|
||||
isRequestAuthenticated,
|
||||
} from '../../lib/auth.js';
|
||||
|
||||
/**
|
||||
* Create auth routes
|
||||
*
|
||||
* @returns Express Router with auth endpoints
|
||||
*/
|
||||
export function createAuthRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
*
|
||||
* Returns whether the current request is authenticated.
|
||||
* Used by the UI to determine if login is needed.
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
const authenticated = isRequestAuthenticated(req);
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated,
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
*
|
||||
* Validates the API key and sets a session cookie.
|
||||
* Body: { apiKey: string }
|
||||
*/
|
||||
router.post('/login', (req, res) => {
|
||||
const { apiKey } = req.body as { apiKey?: string };
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'API key is required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateApiKey(apiKey)) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create session and set cookie
|
||||
const sessionToken = createSession();
|
||||
const cookieOptions = getSessionCookieOptions();
|
||||
const cookieName = getSessionCookieName();
|
||||
|
||||
res.cookie(cookieName, sessionToken, cookieOptions);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logged in successfully.',
|
||||
// Return token for explicit header-based auth (works around cross-origin cookie issues)
|
||||
token: sessionToken,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/token
|
||||
*
|
||||
* Returns the session token if the user has a valid session cookie.
|
||||
* This allows the UI to get a token for explicit header-based auth
|
||||
* after a page refresh (when the token in memory is lost).
|
||||
*/
|
||||
router.get('/token', (req, res) => {
|
||||
const cookieName = getSessionCookieName();
|
||||
const sessionToken = req.cookies?.[cookieName] as string | undefined;
|
||||
|
||||
if (!sessionToken) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'No session cookie found.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the session is still valid
|
||||
if (!isRequestAuthenticated(req)) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Session expired.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return the existing session token for header-based auth
|
||||
res.json({
|
||||
success: true,
|
||||
token: sessionToken,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
*
|
||||
* Clears the session cookie and invalidates the session.
|
||||
*/
|
||||
router.post('/logout', (req, res) => {
|
||||
const cookieName = getSessionCookieName();
|
||||
const sessionToken = req.cookies?.[cookieName] as string | undefined;
|
||||
|
||||
if (sessionToken) {
|
||||
invalidateSession(sessionToken);
|
||||
}
|
||||
|
||||
// Clear the cookie
|
||||
res.clearCookie(cookieName, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logged out successfully.',
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
/**
|
||||
* Health check routes
|
||||
*
|
||||
* NOTE: Only the basic health check (/) is unauthenticated.
|
||||
* The /detailed endpoint requires authentication.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { createIndexHandler } from './routes/index.js';
|
||||
import { createDetailedHandler } from './routes/detailed.js';
|
||||
|
||||
/**
|
||||
* Create unauthenticated health routes (basic check only)
|
||||
* Used by load balancers and container orchestration
|
||||
*/
|
||||
export function createHealthRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Basic health check - no sensitive info
|
||||
router.get('/', createIndexHandler());
|
||||
router.get('/detailed', createDetailedHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Re-export detailed handler for use in authenticated routes
|
||||
export { createDetailedHandler } from './routes/detailed.js';
|
||||
|
||||
@@ -12,12 +12,13 @@ import { ClaudeUsage } from '../routes/claude/types.js';
|
||||
*
|
||||
* Platform-specific implementations:
|
||||
* - macOS: Uses 'expect' command for PTY
|
||||
* - Windows: Uses node-pty for PTY
|
||||
* - Windows/Linux: Uses node-pty for PTY
|
||||
*/
|
||||
export class ClaudeUsageService {
|
||||
private claudeBinary = 'claude';
|
||||
private timeout = 30000; // 30 second timeout
|
||||
private isWindows = os.platform() === 'win32';
|
||||
private isLinux = os.platform() === 'linux';
|
||||
|
||||
/**
|
||||
* Check if Claude CLI is available on the system
|
||||
@@ -48,8 +49,8 @@ export class ClaudeUsageService {
|
||||
* Uses platform-specific PTY implementation
|
||||
*/
|
||||
private executeClaudeUsageCommand(): Promise<string> {
|
||||
if (this.isWindows) {
|
||||
return this.executeClaudeUsageCommandWindows();
|
||||
if (this.isWindows || this.isLinux) {
|
||||
return this.executeClaudeUsageCommandPty();
|
||||
}
|
||||
return this.executeClaudeUsageCommandMac();
|
||||
}
|
||||
@@ -147,17 +148,23 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows implementation using node-pty
|
||||
* Windows/Linux implementation using node-pty
|
||||
*/
|
||||
private executeClaudeUsageCommandWindows(): Promise<string> {
|
||||
private executeClaudeUsageCommandPty(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
let settled = false;
|
||||
let hasSeenUsageData = false;
|
||||
|
||||
const workingDirectory = process.env.USERPROFILE || os.homedir() || 'C:\\';
|
||||
const workingDirectory = this.isWindows
|
||||
? process.env.USERPROFILE || os.homedir() || 'C:\\'
|
||||
: process.env.HOME || os.homedir() || '/tmp';
|
||||
|
||||
const ptyProcess = pty.spawn('cmd.exe', ['/c', 'claude', '/usage'], {
|
||||
// Use platform-appropriate shell and command
|
||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||
|
||||
const ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
@@ -25,10 +26,15 @@ const REFRESH_INTERVAL_SECONDS = 45;
|
||||
|
||||
export function ClaudeUsagePopover() {
|
||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<UsageError | null>(null);
|
||||
|
||||
// Check if CLI is verified/authenticated
|
||||
const isCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
|
||||
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
|
||||
const isStale = useMemo(() => {
|
||||
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
||||
@@ -68,14 +74,17 @@ export function ClaudeUsagePopover() {
|
||||
[setClaudeUsage]
|
||||
);
|
||||
|
||||
// Auto-fetch on mount if data is stale
|
||||
// Auto-fetch on mount if data is stale (only if CLI is verified)
|
||||
useEffect(() => {
|
||||
if (isStale) {
|
||||
if (isStale && isCliVerified) {
|
||||
fetchUsage(true);
|
||||
}
|
||||
}, [isStale, fetchUsage]);
|
||||
}, [isStale, isCliVerified, fetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if CLI is not verified
|
||||
if (!isCliVerified) return;
|
||||
|
||||
// Initial fetch when opened
|
||||
if (open) {
|
||||
if (!claudeUsage || isStale) {
|
||||
@@ -94,7 +103,7 @@ export function ClaudeUsagePopover() {
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [open, claudeUsage, isStale, fetchUsage]);
|
||||
}, [open, claudeUsage, isStale, isCliVerified, fetchUsage]);
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd';
|
||||
import { getJSON, setJSON } from '@/lib/storage';
|
||||
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||
import { useOSDetection } from '@/hooks';
|
||||
import { apiPost } from '@/lib/api-fetch';
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
@@ -98,16 +99,7 @@ export function FileBrowserDialog({
|
||||
setWarning('');
|
||||
|
||||
try {
|
||||
// Get server URL from environment or default
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/fs/browse`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dirPath }),
|
||||
});
|
||||
|
||||
const result: BrowseResult = await response.json();
|
||||
const result = await apiPost<BrowseResult>('/api/fs/browse', { dirPath });
|
||||
|
||||
if (result.success) {
|
||||
setCurrentPath(result.currentPath);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Plus, Bot, Wand2 } from 'lucide-react';
|
||||
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectName: string;
|
||||
@@ -34,12 +35,18 @@ export function BoardHeader({
|
||||
isMounted,
|
||||
}: BoardHeaderProps) {
|
||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
// Only show if CLI has been verified/authenticated
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
||||
const isCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
|
||||
104
apps/ui/src/components/views/login-view.tsx
Normal file
104
apps/ui/src/components/views/login-view.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Login View - Web mode authentication
|
||||
*
|
||||
* Prompts user to enter the API key shown in server console.
|
||||
* On successful login, sets an HTTP-only session cookie.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { login } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
export function LoginView() {
|
||||
const navigate = useNavigate();
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await login(apiKey.trim());
|
||||
if (result.success) {
|
||||
// Redirect to home/board on success
|
||||
navigate({ to: '/' });
|
||||
} else {
|
||||
setError(result.error || 'Invalid API key');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to connect to server');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<KeyRound className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-bold tracking-tight">Authentication Required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Enter the API key shown in the server console to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="apiKey" className="text-sm font-medium">
|
||||
API Key
|
||||
</label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
placeholder="Enter API key..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading || !apiKey.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
'Login'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4 text-sm">
|
||||
<p className="font-medium">Where to find the API key:</p>
|
||||
<ol className="mt-2 list-inside list-decimal space-y-1 text-muted-foreground">
|
||||
<li>Look at the server terminal/console output</li>
|
||||
<li>Find the box labeled "API Key for Web Mode Authentication"</li>
|
||||
<li>Copy the UUID displayed there</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
import { useCliStatus, useSettingsView } from './settings-view/hooks';
|
||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||
@@ -55,11 +56,15 @@ export function SettingsView() {
|
||||
setEnableSandboxMode,
|
||||
} = useAppStore();
|
||||
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
||||
const showUsageTracking = !hasApiKey && !isWindows;
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
defaultDropAnimationSideEffects,
|
||||
} from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiFetch, apiGet, apiPost, apiDeleteRaw, getAuthHeaders } from '@/lib/api-fetch';
|
||||
import { getApiKey } from '@/lib/http-api-client';
|
||||
|
||||
interface TerminalStatus {
|
||||
enabled: boolean;
|
||||
@@ -304,16 +306,13 @@ export function TerminalView() {
|
||||
await Promise.allSettled(
|
||||
sessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
|
||||
} catch (err) {
|
||||
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}, [collectAllSessionIds, terminalState.authToken, serverUrl]);
|
||||
}, [collectAllSessionIds, terminalState.authToken]);
|
||||
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
|
||||
|
||||
// Helper to check if terminal creation should be debounced
|
||||
@@ -434,9 +433,10 @@ export function TerminalView() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`${serverUrl}/api/terminal/status`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>(
|
||||
'/api/terminal/status'
|
||||
);
|
||||
if (data.success && data.data) {
|
||||
setStatus(data.data);
|
||||
if (!data.data.passwordRequired) {
|
||||
setTerminalUnlocked(true);
|
||||
@@ -450,7 +450,7 @@ export function TerminalView() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [serverUrl, setTerminalUnlocked]);
|
||||
}, [setTerminalUnlocked]);
|
||||
|
||||
// Fetch server session settings
|
||||
const fetchServerSettings = useCallback(async () => {
|
||||
@@ -460,15 +460,17 @@ export function TerminalView() {
|
||||
if (terminalState.authToken) {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const data = await apiGet<{
|
||||
success: boolean;
|
||||
data?: { currentSessions: number; maxSessions: number };
|
||||
}>('/api/terminal/settings', { headers });
|
||||
if (data.success && data.data) {
|
||||
setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Terminal] Failed to fetch server settings:', err);
|
||||
}
|
||||
}, [serverUrl, terminalState.isUnlocked, terminalState.authToken]);
|
||||
}, [terminalState.isUnlocked, terminalState.authToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
@@ -483,22 +485,20 @@ export function TerminalView() {
|
||||
const sessionIds = collectAllSessionIds();
|
||||
if (sessionIds.length === 0) return;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (terminalState.authToken) {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
// Try to use the bulk delete endpoint if available, otherwise delete individually
|
||||
// Using sendBeacon for reliability during page unload
|
||||
// Using sync XMLHttpRequest for reliability during page unload (async doesn't complete)
|
||||
sessionIds.forEach((sessionId) => {
|
||||
const url = `${serverUrl}/api/terminal/sessions/${sessionId}`;
|
||||
// sendBeacon doesn't support DELETE method, so we'll use a sync XMLHttpRequest
|
||||
// which is more reliable during page unload than fetch
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('DELETE', url, false); // synchronous
|
||||
xhr.withCredentials = true; // Include cookies for session auth
|
||||
// Add API auth header
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
xhr.setRequestHeader('X-API-Key', apiKey);
|
||||
}
|
||||
// Add terminal-specific auth
|
||||
if (terminalState.authToken) {
|
||||
xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
|
||||
}
|
||||
@@ -593,9 +593,7 @@ export function TerminalView() {
|
||||
let reconnectedSessions = 0;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: Record<string, string> = {};
|
||||
// Get fresh auth token from store
|
||||
const authToken = useAppStore.getState().terminalState.authToken;
|
||||
if (authToken) {
|
||||
@@ -605,11 +603,9 @@ export function TerminalView() {
|
||||
// Helper to check if a session still exists on server
|
||||
const checkSessionExists = async (sessionId: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||
method: 'GET',
|
||||
const data = await apiGet<{ success: boolean }>(`/api/terminal/sessions/${sessionId}`, {
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.success === true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -619,17 +615,12 @@ export function TerminalView() {
|
||||
// Helper to create a new terminal session
|
||||
const createSession = async (): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
cwd: currentPath,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.success ? data.data.id : null;
|
||||
const data = await apiPost<{ success: boolean; data?: { id: string } }>(
|
||||
'/api/terminal/sessions',
|
||||
{ cwd: currentPath, cols: 80, rows: 24 },
|
||||
{ headers }
|
||||
);
|
||||
return data.success && data.data ? data.data.id : null;
|
||||
} catch (err) {
|
||||
console.error('[Terminal] Failed to create terminal session:', err);
|
||||
return null;
|
||||
@@ -801,14 +792,12 @@ export function TerminalView() {
|
||||
setAuthError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await apiPost<{ success: boolean; data?: { token: string }; error?: string }>(
|
||||
'/api/terminal/auth',
|
||||
{ password }
|
||||
);
|
||||
|
||||
if (data.success) {
|
||||
if (data.success && data.data) {
|
||||
setTerminalUnlocked(true, data.data.token);
|
||||
setPassword('');
|
||||
} else {
|
||||
@@ -833,21 +822,14 @@ export function TerminalView() {
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||
method: 'POST',
|
||||
const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
cwd: currentProject?.path || undefined,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}),
|
||||
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -892,21 +874,14 @@ export function TerminalView() {
|
||||
|
||||
const tabId = addTerminalTab();
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||
method: 'POST',
|
||||
const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
cwd: currentProject?.path || undefined,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}),
|
||||
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -959,10 +934,7 @@ export function TerminalView() {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
const response = await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
|
||||
|
||||
// Always remove from UI - even if server says 404 (session may have already exited)
|
||||
removeTerminalFromLayout(sessionId);
|
||||
@@ -1008,10 +980,7 @@ export function TerminalView() {
|
||||
await Promise.all(
|
||||
sessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
|
||||
} catch (err) {
|
||||
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '@/config/terminal-themes';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getApiKey } from '@/lib/http-api-client';
|
||||
|
||||
// Font size constraints
|
||||
const MIN_FONT_SIZE = 8;
|
||||
@@ -940,8 +941,17 @@ export function TerminalPanel({
|
||||
if (!terminal) return;
|
||||
|
||||
const connect = () => {
|
||||
// Build WebSocket URL with token
|
||||
// Build WebSocket URL with auth params
|
||||
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
|
||||
|
||||
// Add API key for Electron mode auth
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
url += `&apiKey=${encodeURIComponent(apiKey)}`;
|
||||
}
|
||||
// In web mode, cookies are sent automatically with same-origin WebSocket
|
||||
|
||||
// Add terminal password token if required
|
||||
if (authToken) {
|
||||
url += `&token=${encodeURIComponent(authToken)}`;
|
||||
}
|
||||
|
||||
161
apps/ui/src/lib/api-fetch.ts
Normal file
161
apps/ui/src/lib/api-fetch.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Authenticated fetch utility
|
||||
*
|
||||
* Provides a wrapper around fetch that automatically includes:
|
||||
* - X-API-Key header (for Electron mode)
|
||||
* - X-Session-Token header (for web mode with explicit token)
|
||||
* - credentials: 'include' (fallback for web mode session cookies)
|
||||
*
|
||||
* Use this instead of raw fetch() for all authenticated API calls.
|
||||
*/
|
||||
|
||||
import { getApiKey, getSessionToken } from './http-api-client';
|
||||
|
||||
// Server URL - configurable via environment variable
|
||||
const getServerUrl = (): string => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const envUrl = import.meta.env.VITE_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
}
|
||||
return 'http://localhost:3008';
|
||||
};
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
|
||||
export interface ApiFetchOptions extends Omit<RequestInit, 'method' | 'headers' | 'body'> {
|
||||
/** Additional headers to include (merged with auth headers) */
|
||||
headers?: Record<string, string>;
|
||||
/** Request body - will be JSON stringified if object */
|
||||
body?: unknown;
|
||||
/** Skip authentication headers (for public endpoints like /api/health) */
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers for an authenticated request
|
||||
*/
|
||||
export function getAuthHeaders(additionalHeaders?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...additionalHeaders,
|
||||
};
|
||||
|
||||
// Electron mode: use API key
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Web mode: use session token if available
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated fetch request to the API
|
||||
*
|
||||
* @param endpoint - API endpoint (e.g., '/api/fs/browse')
|
||||
* @param method - HTTP method
|
||||
* @param options - Additional options
|
||||
* @returns Response from fetch
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Simple GET
|
||||
* const response = await apiFetch('/api/terminal/status', 'GET');
|
||||
*
|
||||
* // POST with body
|
||||
* const response = await apiFetch('/api/fs/browse', 'POST', {
|
||||
* body: { dirPath: '/home/user' }
|
||||
* });
|
||||
*
|
||||
* // With additional headers
|
||||
* const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
* headers: { 'X-Terminal-Token': token },
|
||||
* body: { cwd: '/home/user' }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function apiFetch(
|
||||
endpoint: string,
|
||||
method: HttpMethod = 'GET',
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
|
||||
|
||||
const headers = skipAuth
|
||||
? { 'Content-Type': 'application/json', ...additionalHeaders }
|
||||
: getAuthHeaders(additionalHeaders);
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
...restOptions,
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${getServerUrl()}${endpoint}`;
|
||||
return fetch(url, fetchOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated GET request
|
||||
*/
|
||||
export async function apiGet<T>(
|
||||
endpoint: string,
|
||||
options: Omit<ApiFetchOptions, 'body'> = {}
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(endpoint, 'GET', options);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated POST request
|
||||
*/
|
||||
export async function apiPost<T>(
|
||||
endpoint: string,
|
||||
body?: unknown,
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(endpoint, 'POST', { ...options, body });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated PUT request
|
||||
*/
|
||||
export async function apiPut<T>(
|
||||
endpoint: string,
|
||||
body?: unknown,
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(endpoint, 'PUT', { ...options, body });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated DELETE request
|
||||
*/
|
||||
export async function apiDelete<T>(endpoint: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||
const response = await apiFetch(endpoint, 'DELETE', options);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated DELETE request (returns raw response for status checking)
|
||||
*/
|
||||
export async function apiDeleteRaw(
|
||||
endpoint: string,
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
return apiFetch(endpoint, 'DELETE', options);
|
||||
}
|
||||
@@ -431,6 +431,7 @@ export interface SaveImageResult {
|
||||
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
openDirectory: () => Promise<DialogResult>;
|
||||
openFile: (options?: object) => Promise<DialogResult>;
|
||||
|
||||
@@ -41,12 +41,163 @@ const getServerUrl = (): string => {
|
||||
return 'http://localhost:3008';
|
||||
};
|
||||
|
||||
// Get API key from environment variable
|
||||
const getApiKey = (): string | null => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
|
||||
// Cached API key for authentication (Electron mode only)
|
||||
let cachedApiKey: string | null = null;
|
||||
let apiKeyInitialized = false;
|
||||
|
||||
// Cached session token for authentication (Web mode - explicit header auth)
|
||||
let cachedSessionToken: string | null = null;
|
||||
|
||||
// Get API key for Electron mode (returns cached value after initialization)
|
||||
// Exported for use in WebSocket connections that need auth
|
||||
export const getApiKey = (): string | null => cachedApiKey;
|
||||
|
||||
// Get session token for Web mode (returns cached value after login or token fetch)
|
||||
export const getSessionToken = (): string | null => cachedSessionToken;
|
||||
|
||||
// Set session token (called after login or token fetch)
|
||||
export const setSessionToken = (token: string | null): void => {
|
||||
cachedSessionToken = token;
|
||||
};
|
||||
|
||||
// Clear session token (called on logout)
|
||||
export const clearSessionToken = (): void => {
|
||||
cachedSessionToken = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if we're running in Electron mode
|
||||
*/
|
||||
export const isElectronMode = (): boolean => {
|
||||
return typeof window !== 'undefined' && !!window.electronAPI?.getApiKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize API key for Electron mode authentication.
|
||||
* In web mode, authentication uses HTTP-only cookies instead.
|
||||
*
|
||||
* This should be called early in app initialization.
|
||||
*/
|
||||
export const initApiKey = async (): Promise<void> => {
|
||||
if (apiKeyInitialized) return;
|
||||
apiKeyInitialized = true;
|
||||
|
||||
// Only Electron mode uses API key header auth
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) {
|
||||
try {
|
||||
cachedApiKey = await window.electronAPI.getApiKey();
|
||||
if (cachedApiKey) {
|
||||
console.log('[HTTP Client] Using API key from Electron');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HTTP Client] Failed to get API key from Electron:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// In web mode, authentication is handled via HTTP-only cookies
|
||||
console.log('[HTTP Client] Web mode - using cookie-based authentication');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check authentication status with the server
|
||||
*/
|
||||
export const checkAuthStatus = async (): Promise<{
|
||||
authenticated: boolean;
|
||||
required: boolean;
|
||||
}> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||
credentials: 'include',
|
||||
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
|
||||
});
|
||||
const data = await response.json();
|
||||
return {
|
||||
authenticated: data.authenticated ?? false,
|
||||
required: data.required ?? true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Failed to check auth status:', error);
|
||||
return { authenticated: false, required: true };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Login with API key (for web mode)
|
||||
*/
|
||||
export const login = async (
|
||||
apiKey: string
|
||||
): Promise<{ success: boolean; error?: string; token?: string }> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Store the session token if login succeeded
|
||||
if (data.success && data.token) {
|
||||
setSessionToken(data.token);
|
||||
console.log('[HTTP Client] Session token stored after login');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Login failed:', error);
|
||||
return { success: false, error: 'Network error' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch session token from server (for page refresh when cookie exists)
|
||||
* This retrieves the session token so it can be used for explicit header-based auth.
|
||||
*/
|
||||
export const fetchSessionToken = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/token`, {
|
||||
credentials: 'include', // Send the session cookie
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('[HTTP Client] No valid session to get token from');
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.token) {
|
||||
setSessionToken(data.token);
|
||||
console.log('[HTTP Client] Session token retrieved from cookie session');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Failed to fetch session token:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout (for web mode)
|
||||
*/
|
||||
export const logout = async (): Promise<{ success: boolean }> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Clear the cached session token
|
||||
clearSessionToken();
|
||||
console.log('[HTTP Client] Session token cleared on logout');
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Logout failed:', error);
|
||||
return { success: false };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type EventType =
|
||||
@@ -87,7 +238,22 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.isConnecting = true;
|
||||
|
||||
try {
|
||||
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||
let wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||
|
||||
// In Electron mode, add API key as query param for WebSocket auth
|
||||
// (WebSocket doesn't support custom headers in browser)
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
wsUrl += `?apiKey=${encodeURIComponent(apiKey)}`;
|
||||
} else {
|
||||
// In web mode, add session token as query param
|
||||
// (cookies may not work cross-origin, so use explicit token)
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
wsUrl += `?sessionToken=${encodeURIComponent(sessionToken)}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
@@ -155,10 +321,20 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Electron mode: use API key
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Web mode: use session token if available
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -166,14 +342,17 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async get<T>(endpoint: string): Promise<T> {
|
||||
const headers = this.getHeaders();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, { headers });
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -181,6 +360,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
@@ -190,6 +370,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import path from 'path';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import http, { Server } from 'http';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
|
||||
@@ -59,6 +60,46 @@ interface WindowBounds {
|
||||
// Debounce timer for saving window bounds
|
||||
let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// API key for CSRF protection
|
||||
let apiKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Get path to API key file in user data directory
|
||||
*/
|
||||
function getApiKeyPath(): string {
|
||||
return path.join(app.getPath('userData'), '.api-key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an API key exists - load from file or generate new one.
|
||||
* This key is passed to the server for CSRF protection.
|
||||
*/
|
||||
function ensureApiKey(): string {
|
||||
const keyPath = getApiKeyPath();
|
||||
try {
|
||||
if (fs.existsSync(keyPath)) {
|
||||
const key = fs.readFileSync(keyPath, 'utf-8').trim();
|
||||
if (key) {
|
||||
apiKey = key;
|
||||
console.log('[Electron] Loaded existing API key');
|
||||
return apiKey;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Electron] Error reading API key:', error);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
apiKey = crypto.randomUUID();
|
||||
try {
|
||||
fs.writeFileSync(keyPath, apiKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log('[Electron] Generated new API key');
|
||||
} catch (error) {
|
||||
console.error('[Electron] Failed to save API key:', error);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon path - works in both dev and production, cross-platform
|
||||
*/
|
||||
@@ -331,6 +372,8 @@ async function startServer(): Promise<void> {
|
||||
PORT: SERVER_PORT.toString(),
|
||||
DATA_DIR: app.getPath('userData'),
|
||||
NODE_PATH: serverNodeModules,
|
||||
// Pass API key to server for CSRF protection
|
||||
AUTOMAKER_API_KEY: apiKey!,
|
||||
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
|
||||
// If not set, server will allow access to all paths
|
||||
...(process.env.ALLOWED_ROOT_DIRECTORY && {
|
||||
@@ -509,6 +552,9 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate or load API key for CSRF protection (before starting server)
|
||||
ensureApiKey();
|
||||
|
||||
try {
|
||||
// Start static file server in production
|
||||
if (app.isPackaged) {
|
||||
@@ -666,6 +712,11 @@ ipcMain.handle('server:getUrl', async () => {
|
||||
return `http://localhost:${SERVER_PORT}`;
|
||||
});
|
||||
|
||||
// Get API key for authentication
|
||||
ipcMain.handle('auth:getApiKey', () => {
|
||||
return apiKey;
|
||||
});
|
||||
|
||||
// Window management - update minimum width based on sidebar state
|
||||
// Now uses a fixed small minimum since horizontal scrolling handles overflow
|
||||
ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {
|
||||
|
||||
@@ -19,6 +19,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Get server URL for HTTP client
|
||||
getServerUrl: (): Promise<string> => ipcRenderer.invoke('server:getUrl'),
|
||||
|
||||
// Get API key for authentication
|
||||
getApiKey: (): Promise<string | null> => ipcRenderer.invoke('auth:getApiKey'),
|
||||
|
||||
// Native dialogs - better UX than prompt()
|
||||
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
|
||||
ipcRenderer.invoke('dialog:openDirectory'),
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import {
|
||||
initApiKey,
|
||||
checkAuthStatus,
|
||||
isElectronMode,
|
||||
fetchSessionToken,
|
||||
} from '@/lib/http-api-client';
|
||||
import { Toaster } from 'sonner';
|
||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||
|
||||
@@ -22,6 +28,8 @@ function RootLayoutContent() {
|
||||
const [setupHydrated, setSetupHydrated] = useState(
|
||||
() => useSetupStore.persist?.hasHydrated?.() ?? false
|
||||
);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Hidden streamer panel - opens with "\" key
|
||||
@@ -70,6 +78,51 @@ function RootLayoutContent() {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Initialize authentication
|
||||
// - Electron mode: Uses API key from IPC (header-based auth)
|
||||
// - Web mode: Uses session token (fetched from cookie session for explicit header auth)
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
// Initialize API key for Electron mode
|
||||
await initApiKey();
|
||||
|
||||
// In Electron mode, we're always authenticated via header
|
||||
if (isElectronMode()) {
|
||||
setIsAuthenticated(true);
|
||||
setAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// In web mode, try to fetch session token (works if cookie is valid)
|
||||
// This allows explicit header-based auth which works better cross-origin
|
||||
const tokenFetched = await fetchSessionToken();
|
||||
|
||||
if (tokenFetched) {
|
||||
// We have a valid session - token is now stored in memory
|
||||
setIsAuthenticated(true);
|
||||
setAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: check auth status via cookie
|
||||
const status = await checkAuthStatus();
|
||||
setIsAuthenticated(status.authenticated);
|
||||
setAuthChecked(true);
|
||||
|
||||
// Redirect to login if not authenticated and not already on login page
|
||||
if (!status.authenticated && location.pathname !== '/login') {
|
||||
navigate({ to: '/login' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
setAuthChecked(true);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
// Wait for setup store hydration before enforcing routing rules
|
||||
useEffect(() => {
|
||||
if (useSetupStore.persist?.hasHydrated?.()) {
|
||||
@@ -147,8 +200,32 @@ function RootLayoutContent() {
|
||||
}
|
||||
}, [deferredTheme]);
|
||||
|
||||
// Setup view is full-screen without sidebar
|
||||
// Login and setup views are full-screen without sidebar
|
||||
const isSetupRoute = location.pathname === '/setup';
|
||||
const isLoginRoute = location.pathname === '/login';
|
||||
|
||||
// Show login page (full screen, no sidebar)
|
||||
if (isLoginRoute) {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for auth check before rendering protected routes (web mode only)
|
||||
if (!isElectronMode() && !authChecked) {
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated (web mode)
|
||||
if (!isElectronMode() && !isAuthenticated) {
|
||||
return null; // Will redirect via useEffect
|
||||
}
|
||||
|
||||
if (isSetupRoute) {
|
||||
return (
|
||||
|
||||
6
apps/ui/src/routes/login.tsx
Normal file
6
apps/ui/src/routes/login.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { LoginView } from '@/components/views/login-view';
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
component: LoginView,
|
||||
});
|
||||
@@ -176,6 +176,7 @@ export const useSetupStore = create<SetupState & SetupActions>()(
|
||||
isFirstRun: state.isFirstRun,
|
||||
setupComplete: state.setupComplete,
|
||||
skipClaudeSetup: state.skipClaudeSetup,
|
||||
claudeAuthStatus: state.claudeAuthStatus,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
1
apps/ui/src/types/electron.d.ts
vendored
1
apps/ui/src/types/electron.d.ts
vendored
@@ -464,6 +464,7 @@ export interface AutoModeAPI {
|
||||
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Dialog APIs
|
||||
|
||||
Reference in New Issue
Block a user