Files
gooseek/backend/webui/src/app/(main)/admin/discover/page.tsx
home a0e3748dde 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
2026-02-28 01:33:49 +03:00

514 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
Plus,
Trash2,
GripVertical,
ExternalLink,
ToggleLeft,
ToggleRight,
} from 'lucide-react';
import {
fetchDiscoverCategories,
createDiscoverCategory,
updateDiscoverCategory,
deleteDiscoverCategory,
reorderDiscoverCategories,
fetchDiscoverSources,
createDiscoverSource,
deleteDiscoverSource,
} from '@/lib/api';
import type { DiscoverCategory, DiscoverSource } from '@/lib/types';
interface CategoryModalProps {
category?: DiscoverCategory;
onClose: () => void;
onSave: (data: { name: string; nameRu: string; icon: string; color: string; keywords: string[]; regions: string[] }) => void;
}
function CategoryModal({ category, onClose, onSave }: CategoryModalProps) {
const [name, setName] = useState(category?.name || '');
const [nameRu, setNameRu] = useState(category?.nameRu || '');
const [icon, setIcon] = useState(category?.icon || '📰');
const [color, setColor] = useState(category?.color || '#6B7280');
const [keywords, setKeywords] = useState(category?.keywords?.join(', ') || '');
const [regions, setRegions] = useState(category?.regions?.join(', ') || 'world, russia');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
name,
nameRu,
icon,
color,
keywords: keywords.split(',').map(k => k.trim()).filter(Boolean),
regions: regions.split(',').map(r => r.trim()).filter(Boolean),
});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-primary mb-4">
{category ? 'Редактировать категорию' : 'Новая категория'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-secondary mb-1">ID (англ.)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="tech"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Название (рус.)</label>
<input
type="text"
value={nameRu}
onChange={(e) => setNameRu(e.target.value)}
placeholder="Технологии"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-secondary mb-1">Иконка</label>
<input
type="text"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="💻"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary text-center text-2xl"
required
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Цвет</label>
<div className="flex gap-2">
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-12 h-10 bg-base border border-border/50 rounded-lg cursor-pointer"
/>
<input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
className="flex-1 px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary font-mono"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Ключевые слова (через запятую)</label>
<input
type="text"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
placeholder="technology, AI, software"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Регионы (через запятую)</label>
<input
type="text"
value={regions}
onChange={(e) => setRegions(e.target.value)}
placeholder="world, russia, eu"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-border/50 rounded-lg text-secondary hover:bg-base transition-colors"
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
Сохранить
</button>
</div>
</form>
</div>
</div>
);
}
interface SourceModalProps {
onClose: () => void;
onSave: (data: { name: string; url: string; logoUrl?: string; categories: string[]; trustScore: number; description?: string }) => void;
}
function SourceModal({ onClose, onSave }: SourceModalProps) {
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [logoUrl, setLogoUrl] = useState('');
const [categories, setCategories] = useState('');
const [trustScore, setTrustScore] = useState(0.5);
const [description, setDescription] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
name,
url,
logoUrl: logoUrl || undefined,
categories: categories.split(',').map(c => c.trim()).filter(Boolean),
trustScore,
description: description || undefined,
});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-primary mb-4">Новый источник</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-secondary mb-1">Название</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Habr"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">URL</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://habr.com"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">URL логотипа</label>
<input
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
placeholder="https://..."
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Категории (через запятую)</label>
<input
type="text"
value={categories}
onChange={(e) => setCategories(e.target.value)}
placeholder="tech, science"
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">
Уровень доверия: {trustScore.toFixed(2)}
</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={trustScore}
onChange={(e) => setTrustScore(parseFloat(e.target.value))}
className="w-full"
/>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Описание</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary resize-none"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-border/50 rounded-lg text-secondary hover:bg-base transition-colors"
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
Добавить
</button>
</div>
</form>
</div>
</div>
);
}
export default function AdminDiscoverPage() {
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
const [sources, setSources] = useState<DiscoverSource[]>([]);
const [loading, setLoading] = useState(true);
const [categoryModal, setCategoryModal] = useState<DiscoverCategory | null | undefined>(undefined);
const [showSourceModal, setShowSourceModal] = useState(false);
const [activeTab, setActiveTab] = useState<'categories' | 'sources'>('categories');
const loadData = useCallback(async () => {
setLoading(true);
try {
const [catData, srcData] = await Promise.all([
fetchDiscoverCategories(),
fetchDiscoverSources(),
]);
setCategories(catData.categories || []);
setSources(srcData.sources || []);
} catch (err) {
console.error('Failed to load discover config:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const handleSaveCategory = async (data: { name: string; nameRu: string; icon: string; color: string; keywords: string[]; regions: string[] }) => {
try {
if (categoryModal) {
await updateDiscoverCategory(categoryModal.id, data);
} else {
await createDiscoverCategory(data);
}
setCategoryModal(undefined);
loadData();
} catch (err) {
console.error('Failed to save category:', err);
}
};
const handleDeleteCategory = async (id: string) => {
if (!confirm('Удалить категорию?')) return;
try {
await deleteDiscoverCategory(id);
loadData();
} catch (err) {
console.error('Failed to delete category:', err);
}
};
const handleToggleCategory = async (cat: DiscoverCategory) => {
try {
await updateDiscoverCategory(cat.id, { isActive: !cat.isActive });
loadData();
} catch (err) {
console.error('Failed to toggle category:', err);
}
};
const handleSaveSource = async (data: { name: string; url: string; logoUrl?: string; categories: string[]; trustScore: number; description?: string }) => {
try {
await createDiscoverSource(data);
setShowSourceModal(false);
loadData();
} catch (err) {
console.error('Failed to save source:', err);
}
};
const handleDeleteSource = async (id: string) => {
if (!confirm('Удалить источник?')) return;
try {
await deleteDiscoverSource(id);
loadData();
} catch (err) {
console.error('Failed to delete source:', err);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Discover</h1>
<p className="text-muted">Управление категориями и источниками новостей</p>
</div>
<div className="flex gap-2 border-b border-border/30">
<button
onClick={() => setActiveTab('categories')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'categories'
? 'border-primary text-primary'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
Категории ({categories.length})
</button>
<button
onClick={() => setActiveTab('sources')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'sources'
? 'border-primary text-primary'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
Источники ({sources.length})
</button>
</div>
{activeTab === 'categories' && (
<div className="bg-surface rounded-xl border border-border/30">
<div className="p-4 border-b border-border/30 flex items-center justify-between">
<p className="text-sm text-muted">Перетащите для изменения порядка</p>
<button
onClick={() => setCategoryModal(null)}
className="flex items-center gap-2 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
<div className="divide-y divide-border/20">
{categories.map((cat) => (
<div key={cat.id} className="flex items-center gap-4 px-4 py-3 hover:bg-base/50">
<GripVertical className="w-4 h-4 text-muted cursor-grab" />
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl"
style={{ backgroundColor: cat.color + '20' }}
>
{cat.icon}
</div>
<div className="flex-1">
<p className="text-primary font-medium">{cat.nameRu}</p>
<p className="text-xs text-muted">{cat.name} {cat.keywords.join(', ')}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleToggleCategory(cat)}
className={`p-1 rounded ${cat.isActive ? 'text-green-400' : 'text-muted'}`}
>
{cat.isActive ? <ToggleRight className="w-6 h-6" /> : <ToggleLeft className="w-6 h-6" />}
</button>
<button
onClick={() => setCategoryModal(cat)}
className="p-1 text-muted hover:text-primary rounded"
>
Изменить
</button>
<button
onClick={() => handleDeleteCategory(cat.id)}
className="p-1 text-muted hover:text-red-400 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</div>
)}
{activeTab === 'sources' && (
<div className="bg-surface rounded-xl border border-border/30">
<div className="p-4 border-b border-border/30 flex items-center justify-between">
<p className="text-sm text-muted">Доверенные источники новостей</p>
<button
onClick={() => setShowSourceModal(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
{sources.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted">
<ExternalLink className="w-12 h-12 mb-4 opacity-50" />
<p>Источников пока нет</p>
</div>
) : (
<div className="divide-y divide-border/20">
{sources.map((source) => (
<div key={source.id} className="flex items-center gap-4 px-4 py-3 hover:bg-base/50">
<div className="w-10 h-10 rounded-lg bg-base flex items-center justify-center">
{source.logoUrl ? (
<img src={source.logoUrl} alt="" className="w-6 h-6" />
) : (
<ExternalLink className="w-5 h-5 text-muted" />
)}
</div>
<div className="flex-1">
<p className="text-primary font-medium">{source.name}</p>
<p className="text-xs text-muted">{source.url}</p>
</div>
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted">Доверие:</span>
<span className={`ml-1 font-medium ${
source.trustScore >= 0.7 ? 'text-green-400' :
source.trustScore >= 0.4 ? 'text-yellow-400' : 'text-red-400'
}`}>
{(source.trustScore * 100).toFixed(0)}%
</span>
</div>
<button
onClick={() => handleDeleteSource(source.id)}
className="p-1 text-muted hover:text-red-400 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{categoryModal !== undefined && (
<CategoryModal
category={categoryModal || undefined}
onClose={() => setCategoryModal(undefined)}
onSave={handleSaveCategory}
/>
)}
{showSourceModal && (
<SourceModal
onClose={() => setShowSourceModal(false)}
onSave={handleSaveSource}
/>
)}
</div>
);
}