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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
72
backend/webui/src/components/auth/AuthModal.tsx
Normal file
72
backend/webui/src/components/auth/AuthModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
backend/webui/src/components/auth/ForgotPasswordForm.tsx
Normal file
132
backend/webui/src/components/auth/ForgotPasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
backend/webui/src/components/auth/LoginForm.tsx
Normal file
148
backend/webui/src/components/auth/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
backend/webui/src/components/auth/RegisterForm.tsx
Normal file
252
backend/webui/src/components/auth/RegisterForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
backend/webui/src/components/auth/UserMenu.tsx
Normal file
140
backend/webui/src/components/auth/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
backend/webui/src/components/auth/index.ts
Normal file
5
backend/webui/src/components/auth/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
|
||||
695
backend/webui/src/components/settings/ConnectorsSettings.tsx
Normal file
695
backend/webui/src/components/settings/ConnectorsSettings.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user