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

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