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:
2025-12-18 06:07:22 +01:00
parent cfee1711dc
commit 1186cb1b5e
23 changed files with 2884 additions and 87 deletions

View File

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

View File

@@ -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",

View File

@@ -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": {

View File

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

View File

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

View File

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

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

View File

@@ -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 */}

View File

@@ -2,3 +2,4 @@ export { Header } from './Header';
export { Sidebar } from './Sidebar';
export { MessageList } from './MessageList';
export { ChatInput } from './ChatInput';
export { LoginPage } from './LoginPage';

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

View File

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

View File

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