feat: Add OIDC authentication with Authentik integration
- Add OIDC login flow with Authentik provider - Implement session-based auth with Redis store - Add avatar display from OIDC claims - Fix input field performance with react-textarea-autosize - Stabilize callbacks to prevent unnecessary re-renders - Fix history loading to skip empty session files - Add 2-row default height for input textarea 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,46 @@ server {
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# SPA routing
|
||||
# Backend API proxy (same network namespace via netbird-client)
|
||||
# Using 127.0.0.1 instead of localhost to force IPv4 (avoids IPv6 connection issues)
|
||||
# Note: proxy_pass without URI preserves URL encoding (important for paths with %2F)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# Always HTTPS since NPM handles SSL termination (required for secure cookies)
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
# Auth routes proxy
|
||||
location /auth/ {
|
||||
proxy_pass http://127.0.0.1:3001/auth/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# Always HTTPS since NPM handles SSL termination
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
# WebSocket proxy for Claude sessions
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# Always HTTPS since NPM handles SSL termination
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
}
|
||||
|
||||
# SPA routing for frontend
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
59
frontend/package-lock.json
generated
59
frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3535,6 +3536,22 @@
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-textarea-autosize": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
||||
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"use-composed-ref": "^1.3.0",
|
||||
"use-latest": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -4112,6 +4129,48 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-composed-ref": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
|
||||
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-latest": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
|
||||
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
|
||||
"dependencies": {
|
||||
"use-isomorphic-layout-effect": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { SessionProvider, useSessionManager } from './contexts/SessionContext';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { TabBar } from './components/TabBar';
|
||||
import { ChatPanel } from './components/ChatPanel';
|
||||
import { SplitLayout } from './components/SplitLayout';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { LoginPage } from './components/LoginPage';
|
||||
import { Menu, Loader2 } from 'lucide-react';
|
||||
|
||||
function AppContent() {
|
||||
const {
|
||||
@@ -101,7 +103,30 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
// Loading screen while checking auth
|
||||
function LoadingScreen() {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 text-orange-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-dark-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Auth wrapper - shows login or main app
|
||||
function AuthenticatedApp() {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<AppContent />
|
||||
@@ -109,4 +134,12 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AuthenticatedApp />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect, memo, useCallback } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Send, Square, Command, History, Paperclip, X, Image, FileText } from 'lucide-react';
|
||||
|
||||
// LocalStorage key for input history
|
||||
@@ -212,6 +213,10 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
setHistoryIndex(-1);
|
||||
setSavedInput('');
|
||||
setUploadError(null);
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -221,21 +226,6 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
// Handle input changes for command detection (debounced check, not on every key)
|
||||
const handleInput = useCallback(() => {
|
||||
const message = getMessage();
|
||||
if (message.startsWith('/')) {
|
||||
const query = message.slice(1).toLowerCase();
|
||||
const filtered = COMMANDS.filter(cmd =>
|
||||
cmd.name.toLowerCase().startsWith(query)
|
||||
);
|
||||
setFilteredCommands(filtered);
|
||||
setShowCommands(filtered.length > 0 && message.length > 0);
|
||||
setSelectedIndex(0);
|
||||
} else if (showCommands) {
|
||||
setShowCommands(false);
|
||||
}
|
||||
}, [showCommands]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
// ESC to stop generation
|
||||
@@ -429,15 +419,16 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
<TextareaAutosize
|
||||
ref={textareaRef}
|
||||
defaultValue=""
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
cacheMeasurements
|
||||
className="w-full bg-dark-800 border border-dark-700 rounded-xl px-4 py-3 pr-12 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/20 resize-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { MessageList } from './MessageList';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { StatusBar } from './StatusBar';
|
||||
@@ -94,25 +94,36 @@ const ErrorBanner = memo(function ErrorBanner({ error, onClear }) {
|
||||
|
||||
// Use a separate hook that memoizes everything to prevent unnecessary re-renders
|
||||
function useMemoizedSession(sessionId) {
|
||||
const manager = useSessionManager();
|
||||
const session = manager.sessions[sessionId];
|
||||
const messages = manager.sessionMessages[sessionId] || [];
|
||||
const {
|
||||
sessions,
|
||||
sessionMessages,
|
||||
startClaudeSession,
|
||||
stopClaudeSession,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
clearMessages,
|
||||
changePermissionMode,
|
||||
respondToPermission,
|
||||
} = useSessionManager();
|
||||
|
||||
const session = sessions[sessionId];
|
||||
const messages = sessionMessages[sessionId] || [];
|
||||
|
||||
// Memoize the combined session object
|
||||
const sessionWithMessages = useMemo(() => {
|
||||
return session ? { ...session, messages } : null;
|
||||
}, [session, messages]);
|
||||
|
||||
// Memoize all action functions
|
||||
// Memoize all action functions - use individual functions as deps, not the whole manager
|
||||
const actions = useMemo(() => ({
|
||||
start: () => manager.startClaudeSession(sessionId),
|
||||
stop: () => manager.stopClaudeSession(sessionId),
|
||||
send: (msg, attachments) => manager.sendMessage(sessionId, msg, attachments),
|
||||
stopGeneration: () => manager.stopGeneration(sessionId),
|
||||
clearMessages: () => manager.clearMessages(sessionId),
|
||||
changePermissionMode: (mode) => manager.changePermissionMode(sessionId, mode),
|
||||
respondToPermission: (reqId, allow) => manager.respondToPermission(sessionId, reqId, allow),
|
||||
}), [sessionId, manager]);
|
||||
start: () => startClaudeSession(sessionId),
|
||||
stop: () => stopClaudeSession(sessionId),
|
||||
send: (msg, attachments) => sendMessage(sessionId, msg, attachments),
|
||||
stopGeneration: () => stopGeneration(sessionId),
|
||||
clearMessages: () => clearMessages(sessionId),
|
||||
changePermissionMode: (mode) => changePermissionMode(sessionId, mode),
|
||||
respondToPermission: (reqId, allow) => respondToPermission(sessionId, reqId, allow),
|
||||
}), [sessionId, startClaudeSession, stopClaudeSession, sendMessage, stopGeneration, clearMessages, changePermissionMode, respondToPermission]);
|
||||
|
||||
return { session: sessionWithMessages, ...actions };
|
||||
}
|
||||
@@ -134,11 +145,22 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
||||
// For now, errors auto-clear on next action
|
||||
}, []);
|
||||
|
||||
// Use refs for callbacks to keep them stable across re-renders
|
||||
const sendRef = useRef(send);
|
||||
sendRef.current = send;
|
||||
const stopGenerationRef = useRef(stopGeneration);
|
||||
stopGenerationRef.current = stopGeneration;
|
||||
|
||||
// These callbacks never change identity, preventing ChatInput re-renders
|
||||
const handleSendMessage = useCallback((message, attachments = []) => {
|
||||
if (message.trim() || attachments.length > 0) {
|
||||
send(message, attachments);
|
||||
sendRef.current(message, attachments);
|
||||
}
|
||||
}, [send]);
|
||||
}, []);
|
||||
|
||||
const handleStopGeneration = useCallback(() => {
|
||||
stopGenerationRef.current();
|
||||
}, []);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
@@ -178,7 +200,7 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
||||
{/* Input - memoized props to prevent re-renders during streaming */}
|
||||
<MemoizedChatInput
|
||||
onSend={handleSendMessage}
|
||||
onStop={stopGeneration}
|
||||
onStop={handleStopGeneration}
|
||||
disabled={!session.active}
|
||||
isProcessing={session.isProcessing}
|
||||
sessionId={session.claudeSessionId}
|
||||
|
||||
113
frontend/src/components/LoginPage.jsx
Normal file
113
frontend/src/components/LoginPage.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// Login Page - shows when user is not authenticated
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { LogIn, Shield, Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
export function LoginPage() {
|
||||
const { login, loading, error, authEnabled } = useAuth();
|
||||
|
||||
// Check for error params in URL (from OIDC callback)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const errorParam = params.get('error');
|
||||
if (errorParam) {
|
||||
console.error('[Login] Auth error:', errorParam);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get error from URL if present
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlError = urlParams.get('error');
|
||||
|
||||
const getErrorMessage = (err) => {
|
||||
switch (err) {
|
||||
case 'invalid_state':
|
||||
return 'Session expired. Please try again.';
|
||||
case 'invalid_nonce':
|
||||
return 'Security validation failed. Please try again.';
|
||||
case 'no_access':
|
||||
return 'You do not have access to this application. Contact your administrator.';
|
||||
case 'auth_failed':
|
||||
return 'Authentication failed. Please try again.';
|
||||
default:
|
||||
return err || 'An error occurred during login.';
|
||||
}
|
||||
};
|
||||
|
||||
const displayError = urlError || error;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 text-orange-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-dark-400">Checking authentication...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-950 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo/Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-white font-bold text-2xl">C</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Claude Web UI</h1>
|
||||
<p className="text-dark-400">Sign in to access Claude Code sessions</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{displayError && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-red-400 font-medium">Login Error</p>
|
||||
<p className="text-red-400/80 text-sm mt-1">{getErrorMessage(displayError)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-dark-900 border border-dark-800 rounded-xl p-6">
|
||||
<div className="space-y-4">
|
||||
{/* SSO Info */}
|
||||
<div className="flex items-center gap-3 p-4 bg-dark-800/50 rounded-lg">
|
||||
<Shield className="w-8 h-8 text-orange-400" />
|
||||
<div>
|
||||
<p className="text-dark-200 font-medium">Single Sign-On</p>
|
||||
<p className="text-dark-500 text-sm">Sign in with your organization account</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Button */}
|
||||
<button
|
||||
onClick={() => login('/')}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-orange-600 hover:bg-orange-500 rounded-lg font-medium transition-colors text-white"
|
||||
>
|
||||
<LogIn className="w-5 h-5" />
|
||||
Sign in with SSO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-4 border-t border-dark-800">
|
||||
<p className="text-dark-500 text-xs text-center">
|
||||
Access restricted to authorized users with agent-admin or agent-users group membership.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<p className="text-dark-600 text-xs text-center mt-6">
|
||||
Claude Code Web UI v1.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2 } from 'lucide-react';
|
||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2, LogOut, User, Shield } from 'lucide-react';
|
||||
import { useSessionManager } from '../contexts/SessionContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
||||
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
|
||||
const MAX_RECENT_DIRS = 10;
|
||||
|
||||
@@ -46,6 +46,8 @@ export function Sidebar({ open, onToggle }) {
|
||||
updateSessionConfig,
|
||||
} = useSessionManager();
|
||||
|
||||
const { user, authEnabled, logout, isAdmin } = useAuth();
|
||||
|
||||
const [hosts, setHosts] = useState([]);
|
||||
const [recentDirs, setRecentDirs] = useState([]);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
@@ -63,7 +65,7 @@ export function Sidebar({ open, onToggle }) {
|
||||
|
||||
// Fetch hosts on mount
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/api/hosts`)
|
||||
fetch('/api/hosts', { credentials: 'include' })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setHosts(data.hosts || []);
|
||||
@@ -107,7 +109,9 @@ export function Sidebar({ open, onToggle }) {
|
||||
setBrowserError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/browse?host=${currentHost}&path=${encodeURIComponent(path)}`);
|
||||
const res = await fetch(`/api/browse?host=${currentHost}&path=${encodeURIComponent(path)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
@@ -385,10 +389,52 @@ export function Sidebar({ open, onToggle }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-dark-800 text-xs text-dark-500">
|
||||
<div>Claude Code Web UI</div>
|
||||
<div>Multi-Session Mode</div>
|
||||
{/* User & Footer */}
|
||||
<div className="border-t border-dark-800">
|
||||
{/* User info */}
|
||||
{authEnabled && user && (
|
||||
<div className="p-4 border-b border-dark-800">
|
||||
<div className="flex items-center gap-3">
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name || user.email}
|
||||
className="w-8 h-8 rounded-full flex-shrink-0 object-cover"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center flex-shrink-0"
|
||||
style={{ display: user.avatar ? 'none' : 'flex' }}
|
||||
>
|
||||
<User className="w-4 h-4 text-dark-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-dark-200 truncate">{user.name || user.email}</div>
|
||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||
{isAdmin && <Shield className="w-3 h-3 text-orange-400" />}
|
||||
<span className="truncate">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 hover:bg-dark-700 rounded-lg text-dark-400 hover:text-red-400 transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 text-xs text-dark-500">
|
||||
<div>Claude Code Web UI</div>
|
||||
<div>Multi-Session Mode</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory Browser Modal */}
|
||||
|
||||
@@ -2,3 +2,4 @@ export { Header } from './Header';
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { MessageList } from './MessageList';
|
||||
export { ChatInput } from './ChatInput';
|
||||
export { LoginPage } from './LoginPage';
|
||||
|
||||
197
frontend/src/contexts/AuthContext.jsx
Normal file
197
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
// Authentication Context - handles user auth state and OIDC flow
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [authEnabled, setAuthEnabled] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [expiresAt, setExpiresAt] = useState(null);
|
||||
|
||||
// Check auth status on mount
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
// Set up token refresh interval
|
||||
useEffect(() => {
|
||||
console.log('[AuthContext] Token refresh effect - authEnabled:', authEnabled, 'user:', user?.email, 'expiresAt:', expiresAt);
|
||||
if (!authEnabled || !user || !expiresAt) {
|
||||
console.log('[AuthContext] Token refresh effect - skipping (missing data)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh 5 minutes before expiry
|
||||
const refreshTime = expiresAt - Date.now() - 5 * 60 * 1000;
|
||||
console.log('[AuthContext] Token refresh calculation:', {
|
||||
expiresAt,
|
||||
now: Date.now(),
|
||||
refreshTime,
|
||||
willRefreshIn: refreshTime > 0 ? `${Math.round(refreshTime / 1000)}s` : 'NOW'
|
||||
});
|
||||
|
||||
if (refreshTime <= 0) {
|
||||
// Token already expired or about to, refresh now
|
||||
console.log('[AuthContext] Token expired or about to, refreshing NOW');
|
||||
refreshToken();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[AuthContext] Setting refresh timer for ${Math.round(refreshTime / 1000)}s`);
|
||||
const timer = setTimeout(refreshToken, refreshTime);
|
||||
return () => clearTimeout(timer);
|
||||
}, [authEnabled, user, expiresAt]);
|
||||
|
||||
// Check current auth status
|
||||
const checkAuthStatus = useCallback(async () => {
|
||||
console.log('[AuthContext] checkAuthStatus called');
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/auth/user', {
|
||||
credentials: 'include', // Send cookies
|
||||
});
|
||||
|
||||
console.log('[AuthContext] /auth/user response status:', res.status);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('[AuthContext] /auth/user data:', JSON.stringify(data));
|
||||
console.log('[AuthContext] Setting user:', data.user?.email);
|
||||
console.log('[AuthContext] expiresAt from server:', data.expiresAt, 'Date.now():', Date.now());
|
||||
setUser(data.user);
|
||||
setAuthEnabled(data.authEnabled);
|
||||
setExpiresAt(data.expiresAt || null);
|
||||
setError(null);
|
||||
} else if (res.status === 401) {
|
||||
// Not authenticated
|
||||
console.log('[AuthContext] Not authenticated (401)');
|
||||
setUser(null);
|
||||
// Still need to know if auth is enabled
|
||||
const statusRes = await fetch('/auth/status', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (statusRes.ok) {
|
||||
const statusData = await statusRes.json();
|
||||
setAuthEnabled(statusData.authEnabled);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to check auth status');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] Status check failed:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initiate login - redirect to backend login route
|
||||
const login = useCallback((returnTo = '/') => {
|
||||
window.location.href = `/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
}, []);
|
||||
|
||||
// Logout
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(null);
|
||||
setExpiresAt(null);
|
||||
|
||||
// If there's an OIDC logout URL, redirect to it
|
||||
if (data.logoutUrl) {
|
||||
window.location.href = data.logoutUrl;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] Logout failed:', err);
|
||||
// Even if logout fails, clear local state
|
||||
setUser(null);
|
||||
setExpiresAt(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Refresh token
|
||||
const refreshToken = useCallback(async () => {
|
||||
console.log('[AuthContext] refreshToken called');
|
||||
try {
|
||||
const res = await fetch('/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
console.log('[AuthContext] /auth/refresh response status:', res.status);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('[AuthContext] Token refreshed, new expiresAt:', data.expiresAt);
|
||||
setExpiresAt(data.expiresAt);
|
||||
} else if (res.status === 401) {
|
||||
// Refresh token missing or invalid - but don't log out the user
|
||||
// The session cookie is still valid, just the OIDC token refresh failed
|
||||
// User will be properly logged out when session expires and /auth/user returns 401
|
||||
console.log('[AuthContext] Refresh returned 401 - token refresh unavailable (this is OK, session may still be valid)');
|
||||
// Clear expiresAt to prevent repeated refresh attempts
|
||||
setExpiresAt(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] Token refresh failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Derived state
|
||||
const isAuthenticated = useMemo(() => {
|
||||
// If auth is disabled, everyone is "authenticated"
|
||||
if (!authEnabled) return true;
|
||||
return !!user;
|
||||
}, [authEnabled, user]);
|
||||
|
||||
const isAdmin = useMemo(() => {
|
||||
return user?.isAdmin || false;
|
||||
}, [user]);
|
||||
|
||||
// Context value
|
||||
const value = useMemo(() => ({
|
||||
// State
|
||||
user,
|
||||
authEnabled,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
expiresAt,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
refreshToken,
|
||||
checkAuthStatus,
|
||||
}), [
|
||||
user, authEnabled, loading, error, isAuthenticated, isAdmin, expiresAt,
|
||||
login, logout, refreshToken, checkAuthStatus,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default AuthContext;
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://100.105.142.13:3001';
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
||||
// Build WebSocket URL from current location
|
||||
function getWsUrl() {
|
||||
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
const SESSIONS_STORAGE_KEY = 'claude-webui-sessions';
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
@@ -64,6 +69,10 @@ export function SessionProvider({ children }) {
|
||||
// Current assistant message refs keyed by session ID
|
||||
const currentAssistantMessages = useRef({});
|
||||
|
||||
// Ref to current sessions state (for stable callbacks)
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
// Track if initial load is done (for auto-connecting restored sessions)
|
||||
const initialLoadDone = useRef(false);
|
||||
const sessionsToConnect = useRef([]);
|
||||
@@ -263,6 +272,26 @@ export function SessionProvider({ children }) {
|
||||
for (const toolMsg of toolUseBlocks) {
|
||||
addMessage(sessionId, toolMsg);
|
||||
}
|
||||
|
||||
// Extract usage stats from message if present
|
||||
const usage = message.usage;
|
||||
if (usage) {
|
||||
const inputTokens = (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0);
|
||||
const outputTokens = usage.output_tokens || 0;
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
||||
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
||||
|
||||
updateSession(sessionId, (session) => ({
|
||||
stats: {
|
||||
...(session.stats || {}),
|
||||
inputTokens: (session.stats?.inputTokens || 0) + inputTokens,
|
||||
outputTokens: (session.stats?.outputTokens || 0) + outputTokens,
|
||||
cacheReadTokens: (session.stats?.cacheReadTokens || 0) + cacheReadTokens,
|
||||
cacheCreationTokens: (session.stats?.cacheCreationTokens || 0) + cacheCreationTokens,
|
||||
numTurns: (session.stats?.numTurns || 0) + 1,
|
||||
},
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -342,6 +371,7 @@ export function SessionProvider({ children }) {
|
||||
|
||||
case 'result': {
|
||||
// Final result with stats
|
||||
console.log(`[${sessionId}] Result event:`, JSON.stringify(event, null, 2));
|
||||
const defaultStats = { totalCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, numTurns: 0 };
|
||||
updateSession(sessionId, (session) => ({
|
||||
isProcessing: false,
|
||||
@@ -542,7 +572,7 @@ export function SessionProvider({ children }) {
|
||||
const connectSession = useCallback((sessionId) => {
|
||||
if (wsRefs.current[sessionId]?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
wsRefs.current[sessionId] = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -643,7 +673,7 @@ export function SessionProvider({ children }) {
|
||||
|
||||
// Start Claude session
|
||||
const startClaudeSession = useCallback(async (sessionId) => {
|
||||
const session = sessions[sessionId];
|
||||
const session = sessionsRef.current[sessionId];
|
||||
if (!session) return;
|
||||
|
||||
const ws = wsRefs.current[sessionId];
|
||||
@@ -659,7 +689,8 @@ export function SessionProvider({ children }) {
|
||||
if (session.resumeOnStart) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/history/${encodeURIComponent(session.project)}?host=${session.host}`
|
||||
`/api/history/${encodeURIComponent(session.project)}?host=${session.host}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (data.messages && Array.isArray(data.messages)) {
|
||||
@@ -680,7 +711,7 @@ export function SessionProvider({ children }) {
|
||||
host: session.host,
|
||||
}));
|
||||
}
|
||||
}, [sessions, connectSession, updateSession]);
|
||||
}, [connectSession]);
|
||||
|
||||
// Stop Claude session
|
||||
const stopClaudeSession = useCallback((sessionId) => {
|
||||
@@ -693,7 +724,7 @@ export function SessionProvider({ children }) {
|
||||
|
||||
// Send message to session
|
||||
const sendMessage = useCallback(async (sessionId, message, attachments = []) => {
|
||||
const session = sessions[sessionId];
|
||||
const session = sessionsRef.current[sessionId];
|
||||
if (!session?.active) return;
|
||||
|
||||
const ws = wsRefs.current[sessionId];
|
||||
@@ -708,9 +739,10 @@ export function SessionProvider({ children }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/upload/${session.claudeSessionId}`, {
|
||||
const res = await fetch(`/api/upload/${session.claudeSessionId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
uploadedFiles = data.files || [];
|
||||
@@ -743,7 +775,7 @@ export function SessionProvider({ children }) {
|
||||
type: 'user_message',
|
||||
message: finalMessage,
|
||||
}));
|
||||
}, [sessions, updateSession, addMessage]);
|
||||
}, [updateSession, addMessage]);
|
||||
|
||||
// Stop generation
|
||||
const stopGeneration = useCallback((sessionId) => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://100.105.142.13:3001';
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
||||
// Build WebSocket URL from current location
|
||||
function getWsUrl() {
|
||||
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function useClaudeSession() {
|
||||
@@ -49,7 +54,7 @@ export function useClaudeSession() {
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -350,7 +355,9 @@ export function useClaudeSession() {
|
||||
try {
|
||||
const encodedProject = encodeURIComponent(project);
|
||||
const hostParam = host ? `?host=${host}` : '';
|
||||
const response = await fetch(`${API_URL}/api/history/${encodedProject}${hostParam}`);
|
||||
const response = await fetch(`/api/history/${encodedProject}${hostParam}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
@@ -397,9 +404,10 @@ export function useClaudeSession() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/upload/${sessionId}`, {
|
||||
const response = await fetch(`/api/upload/${sessionId}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user