feat: auth service + security audit fixes + cleanup legacy services

Major changes:
- Add auth-svc: JWT auth, register/login/refresh, password reset
- Add auth UI: modals, pages (/login, /register, /forgot-password)
- Add usage tracking (usage_metrics table, daily limits)
- Add tiered rate limiting (free/pro/business)
- Add LLM usage limits per tier

Security fixes:
- All repos now require userID for Update/Delete operations
- JWT middleware in chat-svc, llm-svc, agent-svc, discover-svc
- ErrNotFound/ErrForbidden errors for proper access control

Cleanup:
- Remove legacy TypeScript services/ directory
- Remove computer-svc (to be reimplemented)
- Remove old deploy/docker configs

New files:
- backend/cmd/auth-svc/main.go
- backend/internal/auth/{types,repository}.go
- backend/internal/usage/{types,repository}.go
- backend/pkg/middleware/{llm_limits,ratelimit_tiered}.go
- backend/webui/src/components/auth/*
- backend/webui/src/app/(auth)/*

Made-with: Cursor
This commit is contained in:
home
2026-02-28 01:33:49 +03:00
parent 120fbbaafb
commit a0e3748dde
523 changed files with 10776 additions and 59630 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useRef, useCallback, KeyboardEvent } from 'react';
import { useState, useRef, useCallback, KeyboardEvent, useEffect, ChangeEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Send,
@@ -13,17 +13,35 @@ import {
Globe,
Image as ImageIcon,
ArrowUp,
X,
FileText,
File,
} from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
type Mode = 'speed' | 'balanced' | 'quality';
export interface ChatAttachment {
id: string;
file: File;
type: 'file' | 'image';
preview?: string;
}
export interface SendOptions {
mode: Mode;
webSearch: boolean;
attachments: ChatAttachment[];
}
interface ChatInputProps {
onSend: (message: string, mode: Mode) => void;
onSend: (message: string, options: SendOptions) => void;
onStop?: () => void;
isLoading?: boolean;
placeholder?: string;
autoFocus?: boolean;
value?: string;
onChange?: (value: string) => void;
}
const modes: { value: Mode; label: string; icon: typeof Zap; desc: string }[] = [
@@ -32,21 +50,81 @@ const modes: { value: Mode; label: string; icon: typeof Zap; desc: string }[] =
{ value: 'quality', label: 'Качество', icon: Sparkles, desc: '~60 сек' },
];
export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }: ChatInputProps) {
const [message, setMessage] = useState('');
export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus, value: externalValue, onChange: externalOnChange }: ChatInputProps) {
const [internalMessage, setInternalMessage] = useState('');
const [mode, setMode] = useState<Mode>('balanced');
const [isFocused, setIsFocused] = useState(false);
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const isControlled = externalValue !== undefined;
const message = isControlled ? externalValue : internalMessage;
const setMessage = isControlled ? (externalOnChange || (() => {})) : setInternalMessage;
useEffect(() => {
if (textareaRef.current && message) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 180)}px`;
}
}, [message]);
const handleSubmit = useCallback(() => {
if (message.trim() && !isLoading) {
onSend(message.trim(), mode);
setMessage('');
if ((message.trim() || attachments.length > 0) && !isLoading) {
onSend(message.trim(), {
mode,
webSearch: webSearchEnabled,
attachments,
});
if (isControlled && externalOnChange) {
externalOnChange('');
} else {
setInternalMessage('');
}
setAttachments([]);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}
}, [message, mode, isLoading, onSend]);
}, [message, mode, webSearchEnabled, attachments, isLoading, onSend, isControlled, externalOnChange]);
const handleFileSelect = useCallback((e: ChangeEvent<HTMLInputElement>, type: 'file' | 'image') => {
const files = e.target.files;
if (!files) return;
const newAttachments: ChatAttachment[] = Array.from(files).map((file) => {
const attachment: ChatAttachment = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
file,
type,
};
if (type === 'image' && file.type.startsWith('image/')) {
attachment.preview = URL.createObjectURL(file);
}
return attachment;
});
setAttachments((prev) => [...prev, ...newAttachments]);
e.target.value = '';
}, []);
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => {
const attachment = prev.find((a) => a.id === id);
if (attachment?.preview) {
URL.revokeObjectURL(attachment.preview);
}
return prev.filter((a) => a.id !== id);
});
}, []);
const toggleWebSearch = useCallback(() => {
setWebSearchEnabled((prev) => !prev);
}, []);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -78,6 +156,44 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }:
${isFocused ? 'shadow-glow-sm' : ''}
`}
>
{/* Attachments Preview */}
{attachments.length > 0 && (
<div className="px-4 pt-3 flex flex-wrap gap-2">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="relative group flex items-center gap-2 bg-surface/80 border border-border/50 rounded-lg px-3 py-2"
>
{attachment.type === 'image' && attachment.preview ? (
<img
src={attachment.preview}
alt={attachment.file.name}
className="w-8 h-8 object-cover rounded"
/>
) : (
<div className="w-8 h-8 flex items-center justify-center bg-accent/10 rounded">
{attachment.file.type.includes('pdf') ? (
<FileText className="w-4 h-4 text-accent" />
) : (
<File className="w-4 h-4 text-accent" />
)}
</div>
)}
<span className="text-xs text-secondary max-w-[120px] truncate">
{attachment.file.name}
</span>
<button
onClick={() => removeAttachment(attachment.id)}
className="ml-1 p-0.5 rounded-full hover:bg-error/20 text-muted hover:text-error transition-colors"
title="Удалить"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* Main Input Area */}
<div className="flex items-end gap-3 p-4 pb-3">
<textarea
@@ -107,16 +223,16 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }:
) : (
<button
onClick={handleSubmit}
disabled={!message.trim()}
disabled={!message.trim() && attachments.length === 0}
className={`
w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-200
${message.trim()
${(message.trim() || attachments.length > 0)
? 'btn-gradient'
: 'bg-surface/50 text-muted border border-border/50'
}
`}
>
<ArrowUp className={`w-5 h-5 ${message.trim() ? 'btn-gradient-text' : ''}`} />
<ArrowUp className={`w-5 h-5 ${(message.trim() || attachments.length > 0) ? 'btn-gradient-text' : ''}`} />
</button>
)}
</div>
@@ -165,23 +281,47 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }:
{/* Action Buttons */}
<button
className="btn-icon w-8 h-8"
title="Веб-поиск"
className={`btn-icon w-8 h-8 ${webSearchEnabled ? 'bg-accent/20 text-accent border-accent/40' : ''}`}
title={webSearchEnabled ? 'Веб-поиск включён' : 'Включить веб-поиск'}
onClick={toggleWebSearch}
type="button"
>
<Globe className="w-4 h-4" />
</button>
<button
className="btn-icon w-8 h-8"
className={`btn-icon w-8 h-8 ${attachments.some(a => a.type === 'file') ? 'text-accent' : ''}`}
title="Прикрепить файл"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<Paperclip className="w-4 h-4" />
</button>
<button
className="btn-icon w-8 h-8"
title="Изображение"
className={`btn-icon w-8 h-8 ${attachments.some(a => a.type === 'image') ? 'text-accent' : ''}`}
title="Прикрепить изображение"
onClick={() => imageInputRef.current?.click()}
type="button"
>
<ImageIcon className="w-4 h-4" />
</button>
{/* Hidden file inputs */}
<input
ref={fileInputRef}
type="file"
className="hidden"
multiple
accept=".pdf,.doc,.docx,.txt,.csv,.json,.xml,.md"
onChange={(e) => handleFileSelect(e, 'file')}
/>
<input
ref={imageInputRef}
type="file"
className="hidden"
multiple
accept="image/*"
onChange={(e) => handleFileSelect(e, 'image')}
/>
</div>
<div className="flex items-center gap-3">

View File

@@ -14,31 +14,43 @@ import {
ChevronRight,
TrendingUp,
BookOpen,
Cpu,
Plane,
HeartPulse,
X,
Shield,
LogIn,
UserPlus,
} from 'lucide-react';
import { filterMenuItems } from '@/lib/config/menu';
import { useAuth } from '@/lib/contexts/AuthContext';
interface SidebarProps {
onClose?: () => void;
}
const navItems = [
const allNavItems = [
{ href: '/', icon: Search, label: 'Поиск' },
{ href: '/discover', icon: Compass, label: 'Discover' },
{ href: '/spaces', icon: FolderOpen, label: 'Пространства' },
{ href: '/history', icon: Clock, label: 'История' },
];
const toolItems = [
{ href: '/computer', icon: Cpu, label: 'Computer' },
const allToolItems = [
{ href: '/travel', icon: Plane, label: 'Путешествия' },
{ href: '/medicine', icon: HeartPulse, label: 'Медицина' },
{ href: '/finance', icon: TrendingUp, label: 'Финансы' },
{ href: '/learning', icon: BookOpen, label: 'Обучение' },
];
const navItems = filterMenuItems(allNavItems);
const toolItems = filterMenuItems(allToolItems);
export function Sidebar({ onClose }: SidebarProps) {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
const { user, isAuthenticated, showAuthModal } = useAuth();
const isMobile = !!onClose;
const isAdmin = user?.role === 'admin';
const toggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev);
@@ -131,7 +143,17 @@ export function Sidebar({ onClose }: SidebarProps) {
</nav>
{/* Footer */}
<div className="p-3 border-t border-border/30">
<div className="p-3 border-t border-border/30 space-y-1">
{isAdmin && (
<NavLink
href="/admin"
icon={Shield}
label="Админка"
collapsed={!isMobile && collapsed}
active={pathname.startsWith('/admin')}
onClick={handleNavClick}
/>
)}
<NavLink
href="/settings"
icon={Settings}
@@ -140,6 +162,79 @@ export function Sidebar({ onClose }: SidebarProps) {
active={pathname === '/settings'}
onClick={handleNavClick}
/>
{/* Auth buttons for non-authenticated users */}
{!isAuthenticated && (
<div className={`pt-2 ${collapsed && !isMobile ? 'px-0' : ''}`}>
{collapsed && !isMobile ? (
<button
onClick={() => showAuthModal('login')}
className="w-full h-10 flex items-center justify-center rounded-xl bg-accent/10 text-accent hover:bg-accent/20 transition-all"
>
<LogIn className="w-[18px] h-[18px]" />
</button>
) : (
<div className="space-y-2">
<button
onClick={() => { showAuthModal('login'); handleNavClick(); }}
className="w-full h-10 flex items-center justify-center gap-2 rounded-xl bg-surface/50 text-secondary hover:text-primary hover:bg-surface transition-all text-sm font-medium"
>
<LogIn className="w-4 h-4" />
Войти
</button>
<button
onClick={() => { showAuthModal('register'); handleNavClick(); }}
className="w-full h-10 flex items-center justify-center gap-2 rounded-xl bg-accent text-white hover:bg-accent-hover transition-all text-sm font-medium"
>
<UserPlus className="w-4 h-4" />
Регистрация
</button>
</div>
)}
</div>
)}
{/* User info for authenticated users */}
{isAuthenticated && user && (
<div className={`pt-2 ${collapsed && !isMobile ? 'justify-center' : ''}`}>
{collapsed && !isMobile ? (
<Link
href="/settings"
className="w-full h-10 flex items-center justify-center rounded-xl hover:bg-surface/50 transition-all"
>
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-sm font-medium text-accent">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
</Link>
) : (
<Link
href="/settings"
onClick={handleNavClick}
className="flex items-center gap-3 px-3 py-2 rounded-xl hover:bg-surface/50 transition-all"
>
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-sm font-medium text-accent">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-primary truncate">{user.name}</div>
<div className="text-xs text-muted truncate">{user.email}</div>
</div>
</Link>
)}
</div>
)}
</div>
</motion.aside>
);

View File

@@ -0,0 +1,72 @@
'use client';
import { useEffect, useCallback } from 'react';
import { X } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { LoginForm } from './LoginForm';
import { RegisterForm } from './RegisterForm';
export function AuthModal() {
const { authModalMode, hideAuthModal, showAuthModal } = useAuth();
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
hideAuthModal();
}
},
[hideAuthModal]
);
useEffect(() => {
if (authModalMode) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [authModalMode, handleKeyDown]);
if (!authModalMode) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
onClick={hideAuthModal}
/>
<div className="relative z-10 w-full max-w-md mx-4 animate-scale-in">
<div className="bg-elevated border border-border rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-primary">
{authModalMode === 'login' ? 'Вход' : 'Регистрация'}
</h2>
<button
onClick={hideAuthModal}
className="p-2 rounded-lg text-muted hover:text-secondary hover:bg-surface/50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6">
{authModalMode === 'login' ? (
<LoginForm
onSuccess={hideAuthModal}
onSwitchToRegister={() => showAuthModal('register')}
/>
) : (
<RegisterForm
onSuccess={hideAuthModal}
onSwitchToLogin={() => showAuthModal('login')}
/>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
'use client';
import { useState, FormEvent } from 'react';
import { Mail, Loader2, ArrowLeft, CheckCircle } from 'lucide-react';
import { forgotPassword } from '@/lib/auth';
interface ForgotPasswordFormProps {
onBack?: () => void;
showTitle?: boolean;
}
export function ForgotPasswordForm({ onBack, showTitle = false }: ForgotPasswordFormProps) {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await forgotPassword(email);
setIsSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка отправки');
} finally {
setIsLoading(false);
}
};
if (isSuccess) {
return (
<div className="text-center py-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-success" />
</div>
<h2 className="text-xl font-semibold text-primary mb-2">Письмо отправлено</h2>
<p className="text-secondary mb-6">
Если аккаунт с email <span className="text-primary font-medium">{email}</span> существует,
вы получите письмо с инструкциями по сбросу пароля.
</p>
{onBack && (
<button
onClick={onBack}
className="inline-flex items-center gap-2 text-accent hover:text-accent-hover font-medium transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Вернуться ко входу
</button>
)}
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
{showTitle && (
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-primary">Сброс пароля</h1>
<p className="text-secondary mt-2">
Введите email и мы отправим ссылку для сброса пароля
</p>
</div>
)}
{!showTitle && (
<p className="text-secondary text-sm">
Введите email, связанный с вашим аккаунтом. Мы отправим ссылку для сброса пароля.
</p>
)}
{error && (
<div className="p-3 rounded-xl bg-error/10 border border-error/30 text-error text-sm">
{error}
</div>
)}
<div>
<label htmlFor="reset-email" className="block text-sm font-medium text-secondary mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="reset-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 px-4 bg-accent hover:bg-accent-hover text-white font-medium
rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Отправка...
</>
) : (
'Отправить ссылку'
)}
</button>
{onBack && (
<button
type="button"
onClick={onBack}
className="w-full py-3 px-4 text-secondary hover:text-primary font-medium
rounded-xl transition-all duration-200 flex items-center justify-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Вернуться ко входу
</button>
)}
</form>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import { useState, FormEvent } from 'react';
import { Mail, Lock, Loader2, Eye, EyeOff } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
interface LoginFormProps {
onSuccess?: () => void;
onSwitchToRegister?: () => void;
showTitle?: boolean;
}
export function LoginForm({ onSuccess, onSwitchToRegister, showTitle = false }: LoginFormProps) {
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login({ email, password });
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка входа');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-5">
{showTitle && (
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-primary">Вход в GooSeek</h1>
<p className="text-secondary mt-2">Войдите в свой аккаунт</p>
</div>
)}
{error && (
<div className="p-3 rounded-xl bg-error/10 border border-error/30 text-error text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary mb-2">
Пароль
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
className="w-full pl-11 pr-11 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-secondary transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 rounded border-border bg-surface text-accent focus:ring-accent/30"
/>
<span className="text-sm text-secondary">Запомнить меня</span>
</label>
<a href="/forgot-password" className="text-sm text-accent hover:text-accent-hover transition-colors">
Забыли пароль?
</a>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 px-4 bg-accent hover:bg-accent-hover text-white font-medium
rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Вход...
</>
) : (
'Войти'
)}
</button>
{onSwitchToRegister && (
<p className="text-center text-sm text-secondary">
Нет аккаунта?{' '}
<button
type="button"
onClick={onSwitchToRegister}
className="text-accent hover:text-accent-hover font-medium transition-colors"
>
Зарегистрироваться
</button>
</p>
)}
</form>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import { useState, FormEvent } from 'react';
import { Mail, Lock, User, Loader2, Eye, EyeOff, Check, X } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
interface RegisterFormProps {
onSuccess?: () => void;
onSwitchToLogin?: () => void;
showTitle?: boolean;
}
export function RegisterForm({ onSuccess, onSwitchToLogin, showTitle = false }: RegisterFormProps) {
const { register } = useAuth();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const passwordRequirements = [
{ met: password.length >= 8, text: 'Минимум 8 символов' },
{ met: /[A-Z]/.test(password), text: 'Заглавная буква' },
{ met: /[a-z]/.test(password), text: 'Строчная буква' },
{ met: /[0-9]/.test(password), text: 'Цифра' },
];
const isPasswordValid = password.length >= 8;
const doPasswordsMatch = password === confirmPassword && confirmPassword.length > 0;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
if (!isPasswordValid) {
setError('Пароль должен содержать минимум 8 символов');
return;
}
if (!doPasswordsMatch) {
setError('Пароли не совпадают');
return;
}
setIsLoading(true);
try {
await register({ email, password, name });
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка регистрации');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-5">
{showTitle && (
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-primary">Регистрация</h1>
<p className="text-secondary mt-2">Создайте аккаунт в GooSeek</p>
</div>
)}
{error && (
<div className="p-3 rounded-xl bg-error/10 border border-error/30 text-error text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-secondary mb-2">
Имя
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
autoComplete="name"
className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
</div>
</div>
<div>
<label htmlFor="reg-email" className="block text-sm font-medium text-secondary mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="reg-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
</div>
</div>
<div>
<label htmlFor="reg-password" className="block text-sm font-medium text-secondary mb-2">
Пароль
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="reg-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="new-password"
className="w-full pl-11 pr-11 py-3 bg-surface/50 border border-border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
transition-all duration-200"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-secondary transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{password.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-1">
{passwordRequirements.map((req, i) => (
<div
key={i}
className={`flex items-center gap-1.5 text-xs ${
req.met ? 'text-success' : 'text-muted'
}`}
>
{req.met ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
{req.text}
</div>
))}
</div>
)}
</div>
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-secondary mb-2">
Подтвердите пароль
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted" />
<input
id="confirm-password"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="new-password"
className={`w-full pl-11 pr-11 py-3 bg-surface/50 border rounded-xl
text-primary placeholder:text-muted
focus:outline-none focus:ring-1 transition-all duration-200
${
confirmPassword.length > 0
? doPasswordsMatch
? 'border-success/50 focus:border-success/50 focus:ring-success/20'
: 'border-error/50 focus:border-error/50 focus:ring-error/20'
: 'border-border focus:border-accent/50 focus:ring-accent/20'
}`}
/>
{confirmPassword.length > 0 && (
<span className="absolute right-3 top-1/2 -translate-y-1/2">
{doPasswordsMatch ? (
<Check className="w-5 h-5 text-success" />
) : (
<X className="w-5 h-5 text-error" />
)}
</span>
)}
</div>
</div>
</div>
<div className="flex items-start gap-2">
<input
id="terms"
type="checkbox"
required
className="w-4 h-4 mt-0.5 rounded border-border bg-surface text-accent focus:ring-accent/30"
/>
<label htmlFor="terms" className="text-sm text-secondary">
Я согласен с{' '}
<a href="/terms" className="text-accent hover:text-accent-hover transition-colors">
условиями использования
</a>{' '}
и{' '}
<a href="/privacy" className="text-accent hover:text-accent-hover transition-colors">
политикой конфиденциальности
</a>
</label>
</div>
<button
type="submit"
disabled={isLoading || !isPasswordValid || !doPasswordsMatch}
className="w-full py-3 px-4 bg-accent hover:bg-accent-hover text-white font-medium
rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Регистрация...
</>
) : (
'Создать аккаунт'
)}
</button>
{onSwitchToLogin && (
<p className="text-center text-sm text-secondary">
Уже есть аккаунт?{' '}
<button
type="button"
onClick={onSwitchToLogin}
className="text-accent hover:text-accent-hover font-medium transition-colors"
>
Войти
</button>
</p>
)}
</form>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { User, Settings, LogOut, CreditCard, Shield, ChevronDown } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
import Link from 'next/link';
export function UserMenu() {
const { user, logout, showAuthModal } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleLogout = async () => {
setIsOpen(false);
await logout();
};
if (!user) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => showAuthModal('login')}
className="px-4 py-2 text-sm font-medium text-secondary hover:text-primary transition-colors"
>
Войти
</button>
<button
onClick={() => showAuthModal('register')}
className="px-4 py-2 text-sm font-medium text-white bg-accent hover:bg-accent-hover rounded-xl transition-colors"
>
Регистрация
</button>
</div>
);
}
const tierBadge = {
free: { label: 'Free', className: 'bg-surface text-secondary' },
pro: { label: 'Pro', className: 'bg-accent/20 text-accent' },
business: { label: 'Business', className: 'bg-success/20 text-success' },
}[user.tier];
return (
<div ref={menuRef} className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-3 px-3 py-2 rounded-xl hover:bg-surface/50 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center overflow-hidden">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
) : (
<span className="text-sm font-medium text-accent">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="hidden sm:block text-left">
<div className="text-sm font-medium text-primary">{user.name}</div>
<div className="text-xs text-muted">{user.email}</div>
</div>
<ChevronDown className={`w-4 h-4 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-64 bg-elevated border border-border rounded-xl shadow-xl overflow-hidden z-50 animate-scale-in">
<div className="p-3 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover rounded-full" />
) : (
<span className="text-base font-medium text-accent">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-primary truncate">{user.name}</div>
<div className="text-xs text-muted truncate">{user.email}</div>
</div>
<span className={`px-2 py-0.5 text-xs font-medium rounded-md ${tierBadge.className}`}>
{tierBadge.label}
</span>
</div>
</div>
<div className="py-1">
<Link
href="/settings"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 text-sm text-secondary hover:text-primary hover:bg-surface/50 transition-colors"
>
<Settings className="w-4 h-4" />
Настройки
</Link>
<Link
href="/settings/billing"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 text-sm text-secondary hover:text-primary hover:bg-surface/50 transition-colors"
>
<CreditCard className="w-4 h-4" />
Подписка
</Link>
{user.role === 'admin' && (
<Link
href="/admin"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 text-sm text-secondary hover:text-primary hover:bg-surface/50 transition-colors"
>
<Shield className="w-4 h-4" />
Админ-панель
</Link>
)}
</div>
<div className="border-t border-border py-1">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm text-error hover:bg-error/10 transition-colors"
>
<LogOut className="w-4 h-4" />
Выйти
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
export { AuthModal } from './AuthModal';
export { LoginForm } from './LoginForm';
export { RegisterForm } from './RegisterForm';
export { ForgotPasswordForm } from './ForgotPasswordForm';
export { UserMenu } from './UserMenu';

View File

@@ -1,5 +1,6 @@
export { Sidebar } from './Sidebar';
export { ChatInput } from './ChatInput';
export type { ChatAttachment, SendOptions } from './ChatInput';
export { ChatMessage } from './ChatMessage';
export { Citation, CitationList } from './Citation';
export { DiscoverCard } from './DiscoverCard';

View File

@@ -0,0 +1,695 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search,
Rss,
TrendingUp,
DollarSign,
Cloud,
Github,
Mail,
Send,
Bell,
Radio,
Check,
X,
ChevronRight,
Loader2,
AlertCircle,
ExternalLink,
Eye,
EyeOff,
} from 'lucide-react';
import type { ConnectorId, ConnectorConfig, ConnectorField, UserConnector, ConnectorCategory } from '@/lib/types';
const CONNECTORS: ConnectorConfig[] = [
{
id: 'web_search',
name: 'Web Search',
description: 'Поиск по интернету через SearXNG',
icon: 'search',
category: 'search',
requiresAuth: false,
fields: [],
actions: ['source'],
},
{
id: 'duckduckgo',
name: 'DuckDuckGo',
description: 'Приватный поиск без отслеживания',
icon: 'search',
category: 'search',
requiresAuth: false,
fields: [],
actions: ['source'],
},
{
id: 'rss',
name: 'RSS Feeds',
description: 'Подписка на RSS/Atom ленты',
icon: 'rss',
category: 'data',
requiresAuth: false,
fields: [
{
key: 'feeds',
label: 'URL лент (по одной на строку)',
type: 'textarea',
placeholder: 'https://habr.com/ru/rss/\nhttps://example.com/feed.xml',
required: true,
helpText: 'Введите URL RSS или Atom лент',
},
],
actions: ['source'],
},
{
id: 'coincap',
name: 'CoinCap',
description: 'Криптовалюты в реальном времени',
icon: 'trending',
category: 'finance',
requiresAuth: false,
fields: [
{
key: 'assets',
label: 'Активы для отслеживания',
type: 'text',
placeholder: 'bitcoin, ethereum, solana',
required: false,
helpText: 'Через запятую. Пусто = все популярные',
},
],
actions: ['source'],
},
{
id: 'exchangerate',
name: 'ExchangeRate',
description: 'Курсы валют в реальном времени',
icon: 'dollar',
category: 'finance',
requiresAuth: false,
fields: [
{
key: 'baseCurrency',
label: 'Базовая валюта',
type: 'text',
placeholder: 'USD',
required: false,
helpText: 'По умолчанию USD',
},
{
key: 'currencies',
label: 'Отслеживаемые валюты',
type: 'text',
placeholder: 'EUR, RUB, GBP',
required: false,
helpText: 'Через запятую',
},
],
actions: ['source'],
},
{
id: 'openmeteo',
name: 'Open-Meteo',
description: 'Погода без API ключа',
icon: 'cloud',
category: 'data',
requiresAuth: false,
fields: [
{
key: 'latitude',
label: 'Широта',
type: 'text',
placeholder: '55.7558',
required: false,
helpText: 'Координаты для прогноза',
},
{
key: 'longitude',
label: 'Долгота',
type: 'text',
placeholder: '37.6173',
required: false,
},
{
key: 'city',
label: 'Или название города',
type: 'text',
placeholder: 'Москва',
required: false,
helpText: 'Будет определено автоматически',
},
],
actions: ['source'],
},
{
id: 'github',
name: 'GitHub',
description: 'Репозитории, issues, релизы',
icon: 'github',
category: 'data',
requiresAuth: true,
fields: [
{
key: 'token',
label: 'Personal Access Token',
type: 'password',
placeholder: 'ghp_xxxxxxxxxxxx',
required: true,
helpText: 'Создайте в Settings → Developer settings → Personal access tokens',
},
{
key: 'repos',
label: 'Репозитории для отслеживания',
type: 'textarea',
placeholder: 'owner/repo\nowner2/repo2',
required: false,
helpText: 'По одному на строку. Пусто = ваши репозитории',
},
],
actions: ['source'],
},
{
id: 'email',
name: 'Email (SMTP)',
description: 'Отправка уведомлений на почту',
icon: 'mail',
category: 'notifications',
requiresAuth: true,
fields: [
{
key: 'host',
label: 'SMTP сервер',
type: 'text',
placeholder: 'smtp.gmail.com',
required: true,
},
{
key: 'port',
label: 'Порт',
type: 'number',
placeholder: '587',
required: true,
},
{
key: 'user',
label: 'Email / Логин',
type: 'text',
placeholder: 'your@email.com',
required: true,
},
{
key: 'password',
label: 'Пароль приложения',
type: 'password',
placeholder: '••••••••',
required: true,
helpText: 'Для Gmail используйте App Password',
},
{
key: 'to',
label: 'Получатель',
type: 'text',
placeholder: 'notify@email.com',
required: true,
helpText: 'Куда отправлять уведомления',
},
],
actions: ['notify'],
},
{
id: 'telegram',
name: 'Telegram Bot',
description: 'Уведомления в Telegram',
icon: 'send',
category: 'notifications',
requiresAuth: true,
fields: [
{
key: 'botToken',
label: 'Bot Token',
type: 'password',
placeholder: '123456789:ABCdefGHI...',
required: true,
helpText: 'Получите у @BotFather',
},
{
key: 'chatId',
label: 'Chat ID',
type: 'text',
placeholder: '123456789',
required: true,
helpText: 'Ваш ID или ID группы. Узнайте у @userinfobot',
},
],
actions: ['notify'],
},
{
id: 'push',
name: 'Push (браузер)',
description: 'Уведомления в браузере',
icon: 'bell',
category: 'notifications',
requiresAuth: false,
fields: [],
actions: ['notify'],
},
{
id: 'websocket',
name: 'WebSocket',
description: 'Подключение к WebSocket потокам',
icon: 'radio',
category: 'data',
requiresAuth: false,
fields: [
{
key: 'url',
label: 'WebSocket URL',
type: 'url',
placeholder: 'wss://stream.example.com/v1/data',
required: true,
},
{
key: 'headers',
label: 'Заголовки (JSON)',
type: 'textarea',
placeholder: '{"Authorization": "Bearer xxx"}',
required: false,
helpText: 'Опционально, в формате JSON',
},
],
actions: ['source'],
},
];
const CATEGORY_LABELS: Record<ConnectorCategory, string> = {
search: 'Поиск',
data: 'Данные',
finance: 'Финансы',
notifications: 'Уведомления',
};
const CATEGORY_ORDER: ConnectorCategory[] = ['search', 'data', 'finance', 'notifications'];
function getIcon(iconName: string) {
const icons: Record<string, React.ElementType> = {
search: Search,
rss: Rss,
trending: TrendingUp,
dollar: DollarSign,
cloud: Cloud,
github: Github,
mail: Mail,
send: Send,
bell: Bell,
radio: Radio,
};
return icons[iconName] || Search;
}
interface ConnectorsSettingsProps {
onSave?: (connectors: UserConnector[]) => void;
}
export function ConnectorsSettings({ onSave }: ConnectorsSettingsProps) {
const [userConnectors, setUserConnectors] = useState<UserConnector[]>([]);
const [expandedId, setExpandedId] = useState<ConnectorId | null>(null);
const [saving, setSaving] = useState<ConnectorId | null>(null);
const [testing, setTesting] = useState<ConnectorId | null>(null);
useEffect(() => {
const saved = localStorage.getItem('userConnectors');
if (saved) {
try {
setUserConnectors(JSON.parse(saved));
} catch {
// ignore
}
}
}, []);
const getUserConnector = useCallback((id: ConnectorId): UserConnector | undefined => {
return userConnectors.find((c) => c.id === id);
}, [userConnectors]);
const updateConnector = useCallback((id: ConnectorId, updates: Partial<UserConnector>) => {
setUserConnectors((prev) => {
const existing = prev.find((c) => c.id === id);
if (existing) {
return prev.map((c) => (c.id === id ? { ...c, ...updates } : c));
}
return [...prev, { id, enabled: false, config: {}, status: 'disconnected', ...updates }];
});
}, []);
const handleToggle = useCallback((id: ConnectorId, enabled: boolean) => {
const connector = CONNECTORS.find((c) => c.id === id);
if (!connector) return;
if (enabled && connector.requiresAuth) {
const userConn = getUserConnector(id);
const hasRequiredFields = connector.fields
.filter((f) => f.required)
.every((f) => userConn?.config[f.key]);
if (!hasRequiredFields) {
setExpandedId(id);
return;
}
}
updateConnector(id, {
enabled,
status: enabled ? 'connected' : 'disconnected',
connectedAt: enabled ? new Date().toISOString() : undefined,
});
}, [getUserConnector, updateConnector]);
const handleSaveConfig = useCallback(async (id: ConnectorId, config: Record<string, string>) => {
setSaving(id);
await new Promise((r) => setTimeout(r, 500));
updateConnector(id, {
config,
enabled: true,
status: 'connected',
connectedAt: new Date().toISOString(),
});
setSaving(null);
setExpandedId(null);
const updated = userConnectors.map((c) =>
c.id === id ? { ...c, config, enabled: true, status: 'connected' as const } : c
);
localStorage.setItem('userConnectors', JSON.stringify(updated));
onSave?.(updated);
}, [userConnectors, updateConnector, onSave]);
const handleTest = useCallback(async (id: ConnectorId) => {
setTesting(id);
await new Promise((r) => setTimeout(r, 1500));
updateConnector(id, { status: 'connected' });
setTesting(null);
}, [updateConnector]);
const handleDisconnect = useCallback((id: ConnectorId) => {
updateConnector(id, {
enabled: false,
status: 'disconnected',
config: {},
});
const updated = userConnectors.filter((c) => c.id !== id);
localStorage.setItem('userConnectors', JSON.stringify(updated));
onSave?.(updated);
}, [userConnectors, updateConnector, onSave]);
const groupedConnectors = CATEGORY_ORDER.map((category) => ({
category,
label: CATEGORY_LABELS[category],
connectors: CONNECTORS.filter((c) => c.category === category),
}));
return (
<div className="space-y-6">
{groupedConnectors.map(({ category, label, connectors }) => (
<div key={category}>
<h3 className="text-xs font-semibold text-muted uppercase tracking-wider mb-3 flex items-center gap-2">
{category === 'search' && <Search className="w-3.5 h-3.5" />}
{category === 'data' && <Rss className="w-3.5 h-3.5" />}
{category === 'finance' && <TrendingUp className="w-3.5 h-3.5" />}
{category === 'notifications' && <Bell className="w-3.5 h-3.5" />}
{label}
</h3>
<div className="space-y-2">
{connectors.map((connector) => (
<ConnectorCard
key={connector.id}
connector={connector}
userConnector={getUserConnector(connector.id)}
expanded={expandedId === connector.id}
onExpand={() => setExpandedId(expandedId === connector.id ? null : connector.id)}
onToggle={(enabled) => handleToggle(connector.id, enabled)}
onSave={(config) => handleSaveConfig(connector.id, config)}
onTest={() => handleTest(connector.id)}
onDisconnect={() => handleDisconnect(connector.id)}
saving={saving === connector.id}
testing={testing === connector.id}
/>
))}
</div>
</div>
))}
</div>
);
}
interface ConnectorCardProps {
connector: ConnectorConfig;
userConnector?: UserConnector;
expanded: boolean;
onExpand: () => void;
onToggle: (enabled: boolean) => void;
onSave: (config: Record<string, string>) => void;
onTest: () => void;
onDisconnect: () => void;
saving: boolean;
testing: boolean;
}
function ConnectorCard({
connector,
userConnector,
expanded,
onExpand,
onToggle,
onSave,
onTest,
onDisconnect,
saving,
testing,
}: ConnectorCardProps) {
const [formData, setFormData] = useState<Record<string, string>>(userConnector?.config || {});
const [showPasswords, setShowPasswords] = useState<Record<string, boolean>>({});
const Icon = getIcon(connector.icon);
const isConnected = userConnector?.enabled && userConnector?.status === 'connected';
const hasError = userConnector?.status === 'error';
const needsConfig = connector.requiresAuth || connector.fields.length > 0;
useEffect(() => {
if (userConnector?.config) {
setFormData(userConnector.config);
}
}, [userConnector?.config]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
const togglePasswordVisibility = (key: string) => {
setShowPasswords((prev) => ({ ...prev, [key]: !prev[key] }));
};
return (
<motion.div
layout
className={`rounded-xl border transition-colors ${
isConnected
? 'bg-emerald-500/5 border-emerald-500/20'
: hasError
? 'bg-red-500/5 border-red-500/20'
: 'bg-elevated/40 border-border/40'
}`}
>
<button
onClick={needsConfig ? onExpand : () => onToggle(!isConnected)}
className="w-full flex items-center gap-3 p-3 sm:p-4"
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
isConnected ? 'bg-emerald-500/10' : 'bg-surface/60'
}`}>
<Icon className={`w-5 h-5 ${isConnected ? 'text-emerald-400' : 'text-secondary'}`} />
</div>
<div className="flex-1 min-w-0 text-left">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-primary">{connector.name}</span>
{isConnected && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400">
Подключён
</span>
)}
{hasError && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">
Ошибка
</span>
)}
{!connector.requiresAuth && connector.fields.length === 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface/60 text-muted">
Без настройки
</span>
)}
</div>
<p className="text-xs text-muted mt-0.5 truncate">{connector.description}</p>
</div>
{needsConfig ? (
<ChevronRight className={`w-4 h-4 text-muted transition-transform ${expanded ? 'rotate-90' : ''}`} />
) : (
<div className={`w-10 h-6 rounded-full relative transition-colors ${
isConnected ? 'bg-emerald-500' : 'bg-surface/80 border border-border'
}`}>
<div className={`absolute top-1 w-4 h-4 rounded-full transition-all ${
isConnected ? 'right-1 bg-white' : 'left-1 bg-secondary'
}`} />
</div>
)}
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<form onSubmit={handleSubmit} className="px-4 pb-4 pt-2 border-t border-border/30">
<div className="space-y-3">
{connector.fields.map((field) => (
<div key={field.key}>
<label className="block text-xs text-secondary mb-1.5">
{field.label}
{field.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
<div className="relative">
{field.type === 'textarea' ? (
<textarea
value={formData[field.key] || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
rows={3}
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-indigo-500/50 resize-none"
/>
) : (
<input
type={field.type === 'password' && !showPasswords[field.key] ? 'password' : 'text'}
value={formData[field.key] || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-indigo-500/50 pr-10"
/>
)}
{field.type === 'password' && (
<button
type="button"
onClick={() => togglePasswordVisibility(field.key)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted hover:text-secondary"
>
{showPasswords[field.key] ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
)}
</div>
{field.helpText && (
<p className="text-[10px] text-muted mt-1">{field.helpText}</p>
)}
</div>
))}
</div>
{hasError && userConnector?.errorMessage && (
<div className="mt-3 p-2 rounded-lg bg-red-500/10 border border-red-500/20 flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-red-400">{userConnector.errorMessage}</p>
</div>
)}
<div className="flex items-center gap-2 mt-4">
<button
type="submit"
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{saving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Сохранение...
</>
) : (
<>
<Check className="w-4 h-4" />
{isConnected ? 'Обновить' : 'Подключить'}
</>
)}
</button>
{isConnected && (
<>
<button
type="button"
onClick={onTest}
disabled={testing}
className="px-3 py-2 bg-surface/60 border border-border/50 text-secondary text-sm rounded-lg hover:bg-surface hover:text-primary transition-colors disabled:opacity-50"
>
{testing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Тест'
)}
</button>
<button
type="button"
onClick={onDisconnect}
className="px-3 py-2 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg hover:bg-red-500/20 transition-colors"
>
<X className="w-4 h-4" />
</button>
</>
)}
</div>
{connector.id === 'github' && (
<a
href="https://github.com/settings/tokens/new"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 mt-3 text-xs text-indigo-400 hover:text-indigo-300"
>
Создать токен на GitHub
<ExternalLink className="w-3 h-3" />
</a>
)}
{connector.id === 'telegram' && (
<a
href="https://t.me/BotFather"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 mt-3 text-xs text-indigo-400 hover:text-indigo-300"
>
Открыть @BotFather
<ExternalLink className="w-3 h-3" />
</a>
)}
</form>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
export default ConnectorsSettings;