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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user