Files
gooseek/backend/webui/src/app/(main)/admin/users/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

387 lines
16 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 {
Search,
Plus,
MoreVertical,
Edit,
Trash2,
UserCheck,
UserX,
Crown,
} from 'lucide-react';
import {
fetchAdminUsers,
createAdminUser,
updateAdminUser,
deleteAdminUser
} from '@/lib/api';
import type { AdminUser, UserRole, UserTier } from '@/lib/types';
interface UserModalProps {
user?: AdminUser;
onClose: () => void;
onSave: (data: { email: string; password?: string; displayName: string; role: UserRole; tier: UserTier }) => void;
}
function UserModal({ user, onClose, onSave }: UserModalProps) {
const [email, setEmail] = useState(user?.email || '');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState(user?.displayName || '');
const [role, setRole] = useState<UserRole>(user?.role || 'user');
const [tier, setTier] = useState<UserTier>(user?.tier || 'free');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
email,
password: password || undefined,
displayName,
role,
tier
});
};
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">
{user ? 'Редактировать пользователя' : 'Новый пользователь'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-secondary mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
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>
{!user && (
<div>
<label className="block text-sm text-secondary mb-1">Пароль</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
required={!user}
/>
</div>
)}
<div>
<label className="block text-sm text-secondary mb-1">Имя</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
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 className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-secondary mb-1">Роль</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</select>
</div>
<div>
<label className="block text-sm text-secondary mb-1">Тариф</label>
<select
value={tier}
onChange={(e) => setTier(e.target.value as UserTier)}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="business">Business</option>
</select>
</div>
</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 AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [modalUser, setModalUser] = useState<AdminUser | null | undefined>(undefined);
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const loadUsers = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAdminUsers(page, 20, search || undefined);
setUsers(data.users);
setTotal(data.total);
} catch (err) {
console.error('Failed to load users:', err);
} finally {
setLoading(false);
}
}, [page, search]);
useEffect(() => {
loadUsers();
}, [loadUsers]);
const handleSave = async (data: { email: string; password?: string; displayName: string; role: UserRole; tier: UserTier }) => {
try {
if (modalUser) {
await updateAdminUser(modalUser.id, data);
} else {
await createAdminUser({ ...data, password: data.password || '' });
}
setModalUser(undefined);
loadUsers();
} catch (err) {
console.error('Failed to save user:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Удалить пользователя?')) return;
try {
await deleteAdminUser(id);
loadUsers();
} catch (err) {
console.error('Failed to delete user:', err);
}
};
const handleToggleActive = async (user: AdminUser) => {
try {
await updateAdminUser(user.id, { isActive: !user.isActive });
loadUsers();
} catch (err) {
console.error('Failed to toggle user:', err);
}
};
const totalPages = Math.ceil(total / 20);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-primary">Пользователи</h1>
<p className="text-muted">Управление пользователями платформы</p>
</div>
<button
onClick={() => setModalUser(null)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
<div className="bg-surface rounded-xl border border-border/30">
<div className="p-4 border-b border-border/30">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input
type="text"
placeholder="Поиск по email или имени..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full pl-10 pr-4 py-2 bg-base border border-border/50 rounded-lg text-primary placeholder:text-muted focus:outline-none focus:border-primary"
/>
</div>
</div>
{loading ? (
<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>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/30">
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Пользователь</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Роль</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Тариф</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Статус</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Создан</th>
<th className="w-12"></th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-border/20 hover:bg-base/50">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="w-10 h-10 rounded-full" />
) : (
<span className="text-primary font-medium">
{user.displayName.charAt(0).toUpperCase()}
</span>
)}
</div>
<div>
<p className="text-primary font-medium">{user.displayName}</p>
<p className="text-xs text-muted">{user.email}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
user.role === 'admin'
? 'bg-purple-500/10 text-purple-400'
: 'bg-blue-500/10 text-blue-400'
}`}>
{user.role === 'admin' && <Crown className="w-3 h-3" />}
{user.role === 'admin' ? 'Админ' : 'Пользователь'}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs ${
user.tier === 'business'
? 'bg-yellow-500/10 text-yellow-400'
: user.tier === 'pro'
? 'bg-green-500/10 text-green-400'
: 'bg-gray-500/10 text-gray-400'
}`}>
{user.tier.toUpperCase()}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
user.isActive
? 'bg-green-500/10 text-green-400'
: 'bg-red-500/10 text-red-400'
}`}>
{user.isActive ? <UserCheck className="w-3 h-3" /> : <UserX className="w-3 h-3" />}
{user.isActive ? 'Активен' : 'Неактивен'}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted">
{new Date(user.createdAt).toLocaleDateString('ru-RU')}
</td>
<td className="px-4 py-3">
<div className="relative">
<button
onClick={() => setActiveMenu(activeMenu === user.id ? null : user.id)}
className="p-1 hover:bg-base rounded"
>
<MoreVertical className="w-4 h-4 text-muted" />
</button>
{activeMenu === user.id && (
<div className="absolute right-0 top-full mt-1 bg-surface border border-border/50 rounded-lg shadow-lg z-10 min-w-[150px]">
<button
onClick={() => {
setModalUser(user);
setActiveMenu(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-base"
>
<Edit className="w-4 h-4" />
Редактировать
</button>
<button
onClick={() => {
handleToggleActive(user);
setActiveMenu(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-base"
>
{user.isActive ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
{user.isActive ? 'Деактивировать' : 'Активировать'}
</button>
<button
onClick={() => {
handleDelete(user.id);
setActiveMenu(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-base"
>
<Trash2 className="w-4 h-4" />
Удалить
</button>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border/30">
<p className="text-sm text-muted">
Показано {users.length} из {total}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
>
Назад
</button>
<span className="px-3 py-1 text-sm text-muted">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
>
Вперёд
</button>
</div>
</div>
)}
</>
)}
</div>
{modalUser !== undefined && (
<UserModal
user={modalUser || undefined}
onClose={() => setModalUser(undefined)}
onSave={handleSave}
/>
)}
</div>
);
}